Compare commits
5 Commits
6e2117188e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1996a123d9 | |||
| c445093186 | |||
| bdf3b55a98 | |||
| 032bc3a580 | |||
| 9fd04b0ce4 |
@@ -20,4 +20,9 @@ venv/
|
||||
# Build outputs
|
||||
build/
|
||||
dist/
|
||||
.build/
|
||||
*.egg-info/
|
||||
|
||||
# Xcode user state
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
@@ -4,16 +4,15 @@ On-demand CLI façade for Reyna family MCP services and LAN devices. The goal is
|
||||
|
||||
## Phase 2 scope
|
||||
|
||||
- **Devices parent command**: All local devices are now under `reyna-cli devices`.
|
||||
- **Devices parent command**: Non-Tactility local devices remain under `reyna-cli devices`; ESP32 boards use the dedicated `reyna-cli tactility` command group.
|
||||
- `reyna-cli devices screen ...` (ESP32 screen)
|
||||
- `reyna-cli devices laptop ...` (personal laptop MCP screen)
|
||||
- `reyna-cli devices computer ...` (this computer's desktop companion client)
|
||||
- `reyna-cli devices iphone ...` (iPhone app)
|
||||
- `reyna-cli devices arm ...` (Robot arm)
|
||||
- **Robot Arm**: Dynamic tool resolution for `state`, `home`, `wave`, `battery`.
|
||||
- **Tactility boards**: mDNS-first discovery with reserved-IP fallback, plus direct web API access for sysinfo, apps, app installation/run, and filesystem operations.
|
||||
- **Immich**: On-demand MCP bridge at `http://127.0.0.1:8626/mcp`.
|
||||
- **MongoDB**: On-demand MCP bridge at `http://127.0.0.1:8630/mcp`.
|
||||
- **Top-level aliases**: `reyna-cli screen`, `reyna-cli laptop`, `reyna-cli iphone`, `reyna-cli arm` still work for compatibility.
|
||||
- **Top-level aliases**: `reyna-cli screen`, `reyna-cli laptop`, and `reyna-cli iphone` still work for compatibility.
|
||||
|
||||
## Install / run
|
||||
|
||||
@@ -22,6 +21,23 @@ uv sync
|
||||
uv run reyna-cli doctor
|
||||
```
|
||||
|
||||
## Signed Python launcher (macOS permissions)
|
||||
|
||||
The manually built, signed **Reyna CLI.app** owns the macOS privacy identity. Use the `signed` wrapper to launch the current Python CLI through that app:
|
||||
|
||||
```bash
|
||||
# Optional: use a different active checkout or interpreter without rebuilding the app.
|
||||
cat > ~/.reyna-cli.env <<'EOF'
|
||||
REYNA_CLI_DIR=/Users/adolforeyna/Projects/platform/reyna-cli
|
||||
REYNA_CLI_PYTHON=/Users/adolforeyna/Projects/platform/reyna-cli/.venv/bin/python
|
||||
EOF
|
||||
|
||||
uv run reyna-cli signed doctor
|
||||
uv run reyna-cli signed local-services speech transcribe-file ./sample.wav --json
|
||||
```
|
||||
|
||||
The signed launcher reads `~/.reyna-cli.env`, then `~/.config/reyna-cli/env`; `REYNA_CLI_DIR` and `REYNA_CLI_PYTHON` environment variables override those values. Python-only edits take effect on the next `signed` run—**do not rebuild or re-sign the app for those edits**. Changes to Swift sources, `Info.plist`, or app signing require a new manual signed Xcode build before they can be used by `signed`.
|
||||
|
||||
## Device discovery
|
||||
|
||||
```bash
|
||||
@@ -29,6 +45,25 @@ uv run reyna-cli devices list --json
|
||||
uv run reyna-cli devices ping esp32_screen --json
|
||||
uv run reyna-cli devices tools esp32_screen --json
|
||||
uv run reyna-cli devices describe esp32_screen --for-hermes
|
||||
|
||||
# Tactility fleet discovery; offline boards remain visible in the JSON report
|
||||
uv run reyna-cli tactility discover --json
|
||||
uv run reyna-cli tactility sysinfo kidsos1 --json
|
||||
uv run reyna-cli tactility apps kidsos1 --json
|
||||
uv run reyna-cli tactility tools Grace --json
|
||||
uv run reyna-cli tactility describe Grace --json
|
||||
uv run reyna-cli tactility call Grace get_screenshot --args '{}' --json
|
||||
uv run reyna-cli tactility call Grace draw_color_bmp --args '{"bmp_base64":"...","x":0,"y":0}' --json
|
||||
uv run reyna-cli tactility call Grace play_tone --args '{"frequency":440,"duration_ms":500,"volume":40}' --json
|
||||
uv run reyna-cli tactility call Grace get_sensors --args '{}' --json
|
||||
uv run reyna-cli tactility install kidsos1 ./build/my.app --json
|
||||
uv run reyna-cli tactility run kidsos1 one.tactility.myapp --json
|
||||
uv run reyna-cli tactility report kidsos1 --json
|
||||
uv run reyna-cli tactility screen clear kidsos1 --width 320 --height 240 --json
|
||||
uv run reyna-cli tactility screen text kidsos1 "Grace\\nReady" --json
|
||||
uv run reyna-cli tactility screen text kidsos1 "Layer intentionally" --no-clear-first --json
|
||||
uv run reyna-cli tactility fs list kidsos1 --path /sdcard --json
|
||||
uv run reyna-cli tactility fs upload kidsos1 ./manifest.json /sdcard/manifest.json --json
|
||||
```
|
||||
|
||||
## Generic MCP calls
|
||||
@@ -37,15 +72,6 @@ uv run reyna-cli devices describe esp32_screen --for-hermes
|
||||
uv run reyna-cli devices call esp32_screen draw_text --args '{"text":"Hello","x":10,"y":20,"size":2}' --json
|
||||
```
|
||||
|
||||
## Robot Arm
|
||||
|
||||
```bash
|
||||
uv run reyna-cli devices arm state
|
||||
uv run reyna-cli devices arm home
|
||||
uv run reyna-cli devices arm wave
|
||||
uv run reyna-cli devices arm battery --json
|
||||
```
|
||||
|
||||
## ESP32 screen, laptop screen, this computer & iPhone
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
# Simple Signed Python Launcher — Future Hardening Plan
|
||||
|
||||
> **Selected implementation (2026-08-15):** The user chose the VoiceAgent-style design now: the manually Xcode-signed `Reyna CLI.app` accepts `--python`, reads `REYNA_CLI_DIR` / `REYNA_CLI_PYTHON` from environment or `~/.reyna-cli.env` / `~/.config/reyna-cli/env`, and runs `python -m reyna_cli.cli` with forwarded arguments. Mutable Python changes therefore take effect without another native rebuild or signing pass. The launcher removes inherited Python interpreter overrides (`PYTHONPATH`, `PYTHONHOME`, `VIRTUAL_ENV`) so Hermes cannot contaminate the selected project venv.
|
||||
>
|
||||
> **Deferred hardening:** The allowlist, owner-safe runtime validation, bounded JSON protocol, and native operation routing described below are deliberately retained as the future hardening design. They are not part of the current simple launcher and must not be represented as its present security boundary.
|
||||
|
||||
> **For Hermes:** Use `subagent-driven-development` skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Let the stable, Apple-signed `Reyna CLI.app` launch a narrowly allowlisted set of changeable Reyna CLI Python operations, so approved macOS privacy access remains attributable to `com.reyna.cli.privacy-host` while Python logic can be updated without rebuilding or re-signing the app.
|
||||
|
||||
**Architecture:** Reuse the existing signed `Reyna CLI.app` / `ReynaCLIHost` as the only privacy identity; do **not** create a second app or turn the AF_UNIX host into a LAN service. Add an exact-operation Swift process runner to the app and a Python adapter that requests only named operations through the app. The Swift layer accepts neither an arbitrary executable nor arbitrary script/module/arguments; it validates an owner-safe runtime configuration, launches a fixed Python module with a bounded JSON request on stdin, and returns one bounded JSON response. Existing native Calendar, Contacts, and Reminders operations stay in Swift and retain their current owner-only socket boundary.
|
||||
|
||||
**Tech Stack:** Swift 6 / Xcode signed macOS app, Foundation `Process`, Python 3.14 / Typer / pytest / uv, JSON-lines protocol, existing Xcode Automatic Signing.
|
||||
|
||||
---
|
||||
|
||||
## Evidence and design decisions
|
||||
|
||||
### What VoiceAgent proved
|
||||
|
||||
- `/Users/adolforeyna/VoiceAgent/swift/VoiceAgentLauncher.swift:35-54` chooses a workspace and Python interpreter from user configuration, then launches `app_main.py` through Foundation `Process` (`:67-80`).
|
||||
- `/Users/adolforeyna/VoiceAgent/build_app.sh:57-83` deliberately skips rebuilding/re-signing when native Swift sources are unchanged; Python files are copied independently. Its `AGENTS.md` describes the intended result: Python edits take effect without a new permission request.
|
||||
- The mechanism is relevant, but its implementation is **not** a safe drop-in: it permits project/Python selection from environment and three env files, then runs an arbitrary workspace entry point. That is acceptable for a personal app launcher, but too broad for Reyna CLI’s signed privacy host.
|
||||
|
||||
### Reyna CLI baseline
|
||||
|
||||
- The existing native identity is already the right foundation: `native/ReynaCLIHost/ReynaCLIHost/Info.plist` declares bundle ID `com.reyna.cli.privacy-host`, and the local bundle verification returned that ID with TeamIdentifier `RHUM5U925W`.
|
||||
- `src/reyna_cli/privacy_host.py:158-176` launches the signed bundle through a per-user LaunchAgent, while `Sources/ReynaCLIHostCore/AppEntry.swift:7-18` currently provides only owner-only socket or stdin JSON-lines modes.
|
||||
- Calendar, Contacts, and Reminders are intentionally native Swift providers. `docs/remaining-coverage-matrix.md:5-20` explicitly keeps direct speech/media paths outside the privacy host; this plan changes that only after a real permission-attribution spike proves the intended path.
|
||||
- Current file/live speech code recompiles source strings to hash-named binaries under `~/Library/Application Support/reyna-cli/speech/` (`src/reyna_cli/speech_execution.py:14-113`, `speech_live.py:17-158`). That defeats a stable executable identity and is the first migration target.
|
||||
|
||||
### Non-goals and invariants
|
||||
|
||||
- No generic “run Python,” shell, script path, module, arbitrary environment, TCP, or LAN RPC interface.
|
||||
- Superseded on 2026-08-15: Apple Notes is now a mutable Python-only CLI route using fixed JXA and the existing Automation approval; it does not modify the signed native host.
|
||||
- No removal/cutover of MacMiniMCP.
|
||||
- No automatic Privacy & Security click. The one live TCC grant is a user-facing, explicit acceptance step.
|
||||
- Never rebuild, replace, or re-sign the installed production app merely because Python changed. A native Swift / plist / entitlement change is a separately approved release.
|
||||
|
||||
## Threat model / chosen boundary
|
||||
|
||||
The mutable Python source is intentionally trusted **as the local user’s Reyna CLI logic**. It can evolve without invalidating the `.app` signature. Therefore the runner must prevent other callers from converting the signed host into a generic privileged code launcher:
|
||||
|
||||
1. The native app accepts a constant allowlist of operation names only (initially `speech.transcribe_file` and `speech.live_session`; add later operations deliberately).
|
||||
2. It selects one fixed Python module for each operation. It never accepts `--script`, `-c`, a module name, an executable path, or shell syntax from the CLI/RPC payload.
|
||||
3. Runtime configuration contains only canonical absolute paths for the Reyna CLI repo and venv Python; it is owner-owned, non-symlinked, and not group/other writable. Environment input cannot override it.
|
||||
4. The request is bounded JSON on stdin and the response is bounded JSON on stdout. Arguments are schema-validated in both Swift and Python; stderr is bounded diagnostic text and never leaks credentials.
|
||||
5. The app only exposes this runner as a foreground/on-demand executable mode, not through the persistent AF_UNIX service. The socket server continues to dispatch Swift-native privacy operations only.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Establish a clean, reproducible baseline and canonical checkout
|
||||
|
||||
**Objective:** Record the baseline and remove the current test ambiguity before changing behavior.
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/plans/2026-08-15-stable-signed-python-permission-runner.md` (this plan)
|
||||
- Create: `docs/permission-runner-baseline.md`
|
||||
- Test: existing Python and Swift suites (no product-code changes)
|
||||
|
||||
**Step 1: Identify the canonical repository path**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /Users/adolforeyna/Projects/reyna-cli
|
||||
pwd -P
|
||||
git rev-parse --show-toplevel
|
||||
git status --short
|
||||
```
|
||||
|
||||
Record the physical path and preserve the existing unrelated working-tree changes. Do not reset, clean, or stage them.
|
||||
|
||||
**Step 2: Reproduce the current verification blockers**
|
||||
|
||||
Run the Python suite with the project interpreter isolated from Hermes’ environment, then run the Swift suite from the same physical checkout:
|
||||
```bash
|
||||
env -i HOME="$HOME" PATH="/opt/homebrew/bin:/usr/bin:/bin" \
|
||||
/Users/adolforeyna/Projects/reyna-cli/.venv/bin/python -m pytest -q
|
||||
cd /Users/adolforeyna/Projects/reyna-cli/native/ReynaCLIHost && swift test
|
||||
```
|
||||
|
||||
Expected current issues to document, not hide:
|
||||
- Python’s `.venv` imports Hermes’ Python 3.11 `pydantic_core` under a Python 3.14 executable.
|
||||
- Swift has duplicate module-cache paths because both `/Users/adolforeyna/Projects/reyna-cli` and `/Users/adolforeyna/Projects/platform/reyna-cli` are being used.
|
||||
|
||||
**Step 3: Repair only the development environment after identifying its owner**
|
||||
|
||||
Use the canonical path exclusively; resolve the venv/module-cache issue without editing application logic. Re-run the same commands and record the exact green baseline. If fixing it would alter a shared environment or discard user work, stop and request scope confirmation.
|
||||
|
||||
**Step 4: Commit the baseline note only after review**
|
||||
|
||||
```bash
|
||||
git add docs/permission-runner-baseline.md docs/plans/2026-08-15-stable-signed-python-permission-runner.md
|
||||
git diff --cached --check
|
||||
git commit -m "docs: record signed Python runner baseline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Specify the fixed runner protocol and runtime configuration (TDD)
|
||||
|
||||
**Objective:** Define a testable contract that makes generic code execution impossible.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/reyna_cli/permission_runner.py`
|
||||
- Create: `tests/test_permission_runner.py`
|
||||
- Create: `native/ReynaCLIHost/Sources/ReynaCLIHostCore/PythonRunnerProtocol.swift`
|
||||
- Create: `native/ReynaCLIHost/Tests/ReynaCLIHostTests/PythonRunnerProtocolTests.swift`
|
||||
|
||||
**Step 1: Write failing Python contract tests**
|
||||
|
||||
Cover:
|
||||
```python
|
||||
def test_operation_allowlist_has_only_named_operations(): ...
|
||||
def test_runtime_config_rejects_relative_symlink_or_group_writable_paths(tmp_path): ...
|
||||
def test_request_rejects_script_module_shell_and_unknown_operation(): ...
|
||||
def test_payload_is_json_object_and_is_bounded(): ...
|
||||
def test_config_does_not_read_environment_overrides(monkeypatch): ...
|
||||
```
|
||||
|
||||
**Step 2: Write failing Swift protocol tests**
|
||||
|
||||
Cover exact operation matching, max request/response size, invalid JSON, extra command-line flags, and a rejection result that contains no runtime secrets.
|
||||
|
||||
**Step 3: Implement the minimal shared contract**
|
||||
|
||||
Use a stable versioned schema:
|
||||
```json
|
||||
{"version":1,"id":"uuid","operation":"speech.transcribe_file","arguments":{"audio_path":"/absolute/path.wav","locale":"en-US"}}
|
||||
```
|
||||
|
||||
Use a Swift `enum PythonOperation: String, CaseIterable` and a Python `Final[frozenset[str]]`; do not duplicate untested string maps across call sites. Put the fixed module mapping in Swift, for example:
|
||||
```swift
|
||||
case .speechTranscribeFile: return "reyna_cli.permission_runner"
|
||||
```
|
||||
|
||||
**Step 4: Define runtime configuration**
|
||||
|
||||
Use one fixed per-user file:
|
||||
`~/Library/Application Support/reyna-cli/privacy/python-runtime.json`
|
||||
|
||||
It contains only:
|
||||
```json
|
||||
{"version":1,"repo_root":"/absolute/canonical/reyna-cli","python":"/absolute/canonical/reyna-cli/.venv/bin/python"}
|
||||
```
|
||||
|
||||
Validate its path components with the same owner/no-symlink discipline used for the native socket. Reject a missing, non-regular, symlinked, foreign-owned, or group/other-writable config, repo, Python executable, and source package path. The config is not an environment file and has no token fields.
|
||||
|
||||
**Step 5: Verify**
|
||||
|
||||
```bash
|
||||
env -u VIRTUAL_ENV uv run pytest tests/test_permission_runner.py -v
|
||||
cd native/ReynaCLIHost && swift test --filter PythonRunnerProtocolTests
|
||||
```
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/reyna_cli/permission_runner.py tests/test_permission_runner.py \
|
||||
native/ReynaCLIHost/Sources/ReynaCLIHostCore/PythonRunnerProtocol.swift \
|
||||
native/ReynaCLIHost/Tests/ReynaCLIHostTests/PythonRunnerProtocolTests.swift
|
||||
git diff --cached --check
|
||||
git commit -m "feat: define restricted signed Python runner protocol"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add the signed Swift process runner without changing the socket host
|
||||
|
||||
**Objective:** Make `ReynaCLIHost` the parent process for one allowlisted Python operation and preserve the existing native socket behavior byte-for-byte.
|
||||
|
||||
**Files:**
|
||||
- Create: `native/ReynaCLIHost/Sources/ReynaCLIHostCore/PythonOperationRunner.swift`
|
||||
- Create: `native/ReynaCLIHost/Tests/ReynaCLIHostTests/PythonOperationRunnerTests.swift`
|
||||
- Modify: `native/ReynaCLIHost/Sources/ReynaCLIHostCore/AppEntry.swift`
|
||||
- Modify: `native/ReynaCLIHost/Package.swift`
|
||||
- Modify: `native/ReynaCLIHost/ReynaCLIHost.xcodeproj/project.pbxproj`
|
||||
- Test: `native/ReynaCLIHost/Tests/ReynaCLIHostTests/HostIntegrationTests.swift`
|
||||
|
||||
**Step 1: Write failing runner tests**
|
||||
|
||||
Use an injected process factory. Assert:
|
||||
- command is exactly `[configuredPython, "-m", "reyna_cli.permission_runner", "--operation", exactOperation]`;
|
||||
- working directory is the validated repo root;
|
||||
- only a minimal fixed environment is passed (`HOME`, `PATH`, `LANG`, `LC_*` as explicitly required), not the parent process environment;
|
||||
- JSON is written once to stdin; stdout is a single bounded JSON object; nonzero exit, timeout, malformed response, or oversized output becomes a safe structured error;
|
||||
- `--socket` never dispatches to this runner.
|
||||
|
||||
**Step 2: Extend `runReynaCLIHost` with one exact mode**
|
||||
|
||||
Add only:
|
||||
```text
|
||||
--python-operation <operation>
|
||||
```
|
||||
|
||||
Reject duplicate flags, positional leftovers, `--socket` combinations, unknown operations, and absent input. Keep the existing `--socket <path>` and stdin native JSON-lines behavior unchanged.
|
||||
|
||||
**Step 3: Update both build systems**
|
||||
|
||||
Add new Swift files to `Package.swift` automatically by target path and add file/build references in the Xcode project. Do not alter bundle identifier, signing configuration, Info.plist privacy keys, or the existing EventKit/Contacts linkage.
|
||||
|
||||
**Step 4: Verify**
|
||||
|
||||
```bash
|
||||
cd native/ReynaCLIHost
|
||||
swift test --filter PythonOperationRunnerTests
|
||||
swift test --filter HostIntegrationTests
|
||||
xcodebuild -project ReynaCLIHost.xcodeproj -scheme 'Reyna CLI' \
|
||||
-configuration Release CODE_SIGNING_ALLOWED=NO build
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add native/ReynaCLIHost
|
||||
git diff --cached --check
|
||||
git commit -m "feat: add allowlisted Python operation runner to signed host"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement the mutable Python operation adapter (TDD)
|
||||
|
||||
**Objective:** Receive the bounded request, validate it again, and execute only the named Reyna CLI operation.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/reyna_cli/permission_runner.py`
|
||||
- Modify: `tests/test_permission_runner.py`
|
||||
- Create: `tests/test_permission_runner_integration.py`
|
||||
|
||||
**Step 1: Write failing adapter tests**
|
||||
|
||||
Tests must prove that the adapter:
|
||||
- reads exactly one request object from stdin and returns one JSON object;
|
||||
- rejects an extra line, unsupported schema version, unknown operation, non-absolute/unreadable input path, missing locale, and unknown keys;
|
||||
- never invokes `shell=True`, `os.system`, `exec`, or dynamic import based on input;
|
||||
- strips/truncates child diagnostics and never returns environment values;
|
||||
- invokes an internal function table keyed by constant operation names.
|
||||
|
||||
**Step 2: Implement operation handlers as ordinary Python functions**
|
||||
|
||||
Start with only the two speech operations. Each operation receives a typed dataclass, not a raw dict. Return an operation-specific typed response. Keep any long-lived live-transcription child owned by the signed host invocation; do not daemonize it through the privacy socket.
|
||||
|
||||
**Step 3: Add an end-to-end test harness**
|
||||
|
||||
Use a temporary validated repo/config plus a harmless Python fixture module or injected runner seam. Verify host → Python request/response shape, negative paths, and that the host’s executable is the immediate parent. This is a structural test, not evidence of TCC attribution.
|
||||
|
||||
**Step 4: Verify**
|
||||
|
||||
```bash
|
||||
env -u VIRTUAL_ENV uv run pytest tests/test_permission_runner.py tests/test_permission_runner_integration.py -v
|
||||
cd native/ReynaCLIHost && swift test
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/reyna_cli/permission_runner.py tests/test_permission_runner.py tests/test_permission_runner_integration.py
|
||||
git diff --cached --check
|
||||
git commit -m "feat: add validated mutable Python operation adapter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add the Reyna CLI client route and preserve compatibility (TDD)
|
||||
|
||||
**Objective:** Route supported CLI operations through the signed host without making the host a generic subprocess service.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/reyna_cli/signed_runner_client.py`
|
||||
- Create: `tests/test_signed_runner_client.py`
|
||||
- Modify: `src/reyna_cli/cli.py`
|
||||
- Modify: `src/reyna_cli/privacy_host.py`
|
||||
- Modify: `tests/test_reyna.py`
|
||||
- Modify: `tests/test_privacy_host.py`
|
||||
|
||||
**Step 1: Write failing client tests**
|
||||
|
||||
Test the client builds the exact signed-app executable path from `app_bundle_executable_path()`, sends the versioned JSON request over stdin, enforces timeouts/output caps, and fails closed if `validate_app_bundle()` is not signed/valid. Test that no app validation output leaks signing identity data.
|
||||
|
||||
**Step 2: Implement `SignedRunnerClient`**
|
||||
|
||||
Use direct argument vectors:
|
||||
```python
|
||||
[app_executable, "--python-operation", operation]
|
||||
```
|
||||
Pass request JSON via `input=...`; never encode it in a shell command, URL, environment variable, or temporary world-readable file.
|
||||
|
||||
**Step 3: Wire explicit speech commands behind a feature gate**
|
||||
|
||||
Add a `--via-signed-host` internal/experimental option first, defaulting to the old direct code until the live TCC spike passes. The default does not change in this task. This provides an easy rollback and avoids claiming migration before permission identity is verified.
|
||||
|
||||
**Step 4: Verify**
|
||||
|
||||
```bash
|
||||
env -u VIRTUAL_ENV uv run pytest tests/test_signed_runner_client.py tests/test_reyna.py tests/test_privacy_host.py -q
|
||||
env -u VIRTUAL_ENV uv run reyna-cli local-services speech transcribe-file --help
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/reyna_cli/signed_runner_client.py src/reyna_cli/cli.py src/reyna_cli/privacy_host.py tests/
|
||||
git diff --cached --check
|
||||
git commit -m "feat: route opt-in speech calls through signed runner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Replace runtime Swift compilation only after the live permission spike passes
|
||||
|
||||
**Objective:** Eliminate hash-named runtime binaries for migrated speech operations while keeping mutable Python orchestration.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/reyna_cli/speech_execution.py`
|
||||
- Modify: `src/reyna_cli/speech_live.py`
|
||||
- Modify: `src/reyna_cli/permission_runner.py`
|
||||
- Modify: `tests/test_media_execution.py`
|
||||
- Modify: `tests/test_permission_runner.py`
|
||||
- Modify: `docs/remaining-coverage-matrix.md`
|
||||
|
||||
**Step 1: Write red migration tests**
|
||||
|
||||
Assert signed-runner paths do not call `swiftc`, do not create hash-named binaries, and return `source: "reyna_cli_signed_host"`. Preserve a direct-path regression test until cutover approval.
|
||||
|
||||
**Step 2: Choose the minimal proven execution shape**
|
||||
|
||||
After the spike, choose one of these based on real evidence:
|
||||
- **Preferred:** move the SpeechAnalyzer implementation into a signed Swift operation in `ReynaCLIHost`; Python remains mutable validation/orchestration around it.
|
||||
- **Only if the spike proves TCC attribution through the Python child:** retain the Python operation runner and invoke the required trusted helper under the signed host, with no runtime compilation.
|
||||
|
||||
Do not infer that a child process inherits usable TCC authorization merely because its parent is bundle-associated. The live prompt and post-edit run are the acceptance evidence.
|
||||
|
||||
**Step 3: Keep live-session ownership clear**
|
||||
|
||||
The client process owns the signed-host child for the duration of `speech.live_session`; closure sends EOF and waits. It must not leave an orphan, long-running process or open listening endpoint.
|
||||
|
||||
**Step 4: Verify**
|
||||
|
||||
```bash
|
||||
env -u VIRTUAL_ENV uv run pytest tests/test_media_execution.py tests/test_permission_runner.py -q
|
||||
cd native/ReynaCLIHost && swift test
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/reyna_cli/speech_execution.py src/reyna_cli/speech_live.py src/reyna_cli/permission_runner.py tests/test_media_execution.py tests/test_permission_runner.py docs/remaining-coverage-matrix.md
|
||||
git diff --cached --check
|
||||
git commit -m "refactor: run speech through stable signed host"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Build, sign, and run the one-time TCC acceptance test
|
||||
|
||||
**Objective:** Prove the real macOS behavior that the design is intended to preserve.
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/remaining-coverage-matrix.md`
|
||||
- Create: `docs/permission-runner-verification.md`
|
||||
|
||||
**Step 1: Prepare before requesting permission**
|
||||
|
||||
Run all automated checks first. Build the app with its existing Xcode Automatic Signing configuration. Validate it using the existing `reyna-cli privacy-host status` / app-bundle validation path and confirm it is not ad-hoc.
|
||||
|
||||
**Step 2: Ask the user before the live privileged run**
|
||||
|
||||
Explain in one sentence: “Reyna CLI will request Speech Recognition access under its own signed app identity; no audio is uploaded.” Wait for explicit approval before launching the operation that triggers the OS prompt.
|
||||
|
||||
**Step 3: Grant once and verify attribution**
|
||||
|
||||
After the user grants it, run a known local fixture through `--via-signed-host`. Record only pass/fail and visible app identity, not TCC database contents, secrets, or unrelated privacy records.
|
||||
|
||||
**Step 4: Verify the promised update behavior**
|
||||
|
||||
1. Change a harmless Python response marker (not Swift, plist, bundle ID, entitlement, or signing setting).
|
||||
2. Re-run the same signed-host operation.
|
||||
3. Confirm the changed Python behavior is present and macOS does **not** request permission again.
|
||||
4. Revert the harmless marker.
|
||||
|
||||
This is the key acceptance criterion. If it fails, revert the feature gate to direct mode and document the result; do not repeatedly prompt or rebuild blindly.
|
||||
|
||||
**Step 5: Commit evidence/docs**
|
||||
|
||||
```bash
|
||||
git add docs/remaining-coverage-matrix.md docs/permission-runner-verification.md
|
||||
git diff --cached --check
|
||||
git commit -m "docs: verify signed Python permission runner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Controlled default cutover and release verification
|
||||
|
||||
**Objective:** Make signed-host execution the default only after the verified acceptance test and preserve rollback.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/reyna_cli/cli.py`
|
||||
- Modify: `src/reyna_cli/signed_runner_client.py`
|
||||
- Modify: `tests/test_reyna.py`
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/remaining-coverage-matrix.md`
|
||||
|
||||
**Step 1: Write red default/rollback tests**
|
||||
|
||||
Verify supported speech commands default to the signed route when the signed bundle validates, but `--direct` remains a documented temporary fallback. If the signed bundle is missing, ad-hoc, invalid, or operation unsupported, the command fails plainly; it does not silently use an untrusted generic subprocess route.
|
||||
|
||||
**Step 2: Switch the default and document boundary**
|
||||
|
||||
Document which operations are: (a) signed privacy-host runner, (b) direct local CLI, and (c) remote service. State that Python logic may change independently, but native host changes require a signed app release and may require a new permission review.
|
||||
|
||||
**Step 3: Full verification**
|
||||
|
||||
```bash
|
||||
env -u VIRTUAL_ENV uv run pytest -q
|
||||
cd native/ReynaCLIHost && swift test
|
||||
cd /Users/adolforeyna/Projects/reyna-cli
|
||||
xcodebuild -project native/ReynaCLIHost/ReynaCLIHost.xcodeproj -scheme 'Reyna CLI' \
|
||||
-configuration Release CODE_SIGNING_ALLOWED=NO build
|
||||
env -u VIRTUAL_ENV uv run reyna-cli privacy-host status --json
|
||||
env -u VIRTUAL_ENV uv run reyna-cli local-services speech transcribe-file --help
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
Then repeat the signed live smoke test only with the user’s prior approval and confirm no new prompt occurs after a Python-only edit.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/reyna_cli/cli.py src/reyna_cli/signed_runner_client.py tests/test_reyna.py README.md docs/remaining-coverage-matrix.md
|
||||
git diff --cached --check
|
||||
git commit -m "feat: default speech execution to stable signed host"
|
||||
```
|
||||
|
||||
## Release acceptance checklist
|
||||
|
||||
- [ ] Native host has the existing stable bundle ID and a non-ad-hoc Xcode signature.
|
||||
- [ ] There is no generic Python/script/shell execution interface.
|
||||
- [ ] Socket IPC remains owner-only and does not dispatch Python operations.
|
||||
- [ ] Calendar, Contacts, Reminders native-host regressions and direct Notes CLI regressions remain green.
|
||||
- [ ] A Python-only logic edit changes behavior without rebuilding/re-signing the app.
|
||||
- [ ] The post-edit operation succeeds without a second TCC prompt.
|
||||
- [ ] No new daemon, TCP listener, copied credential, or unbounded log was introduced.
|
||||
- [ ] Direct fallback remains available until the owner explicitly approves its retirement.
|
||||
@@ -0,0 +1,33 @@
|
||||
# MacMiniMCP → Reyna CLI Coverage Matrix
|
||||
|
||||
_Status: 2026-08-03. This document records the current owned boundary; it does not authorize retirement of MacMiniMCP._
|
||||
|
||||
## Completed native privacy-host capabilities
|
||||
|
||||
- **Calendar** — native EventKit through the signed Reyna CLI host. Authorization is explicit; normal reads do not prompt.
|
||||
- **Contacts** — native Contacts framework through the signed host. Authorization is explicit; normal reads do not prompt.
|
||||
- **Reminders** — native EventKit Reminders through the signed host. Authorization is explicit; list/list-item calls are read-only; creation requires an explicit writable list.
|
||||
- **System status** — bounded, whitelisted native status paths only.
|
||||
|
||||
The host remains owner-only AF_UNIX IPC. It does not expose TCP or a LAN service.
|
||||
|
||||
## Direct local-service ownership (no MacMiniMCP)
|
||||
|
||||
- Speech / unified voice generation (Kokoro, Kyutai Pocket JV, and Qwen3-TTS JV), Voicebox, image, and Apple-LLM execution are available through direct Reyna CLI paths. These remain outside the signed privacy host: they use local subprocesses, loopback media daemons, or explicitly configured external APIs.
|
||||
- Speech file transcription uses a Reyna CLI-owned cached SpeechAnalyzer helper.
|
||||
- Speech live transcription uses a Reyna CLI-owned persistent pipe session and warmed helper process.
|
||||
- Kokoro uses the existing warm `ksay` daemon; Pocket uses the local Kyutai runtime with the verified `jv_pocket.pt` state; Qwen3-TTS uses the local MLX JV clone; Voicebox uses its local generation API; Codex/Gemini image generation runs directly from Reyna CLI.
|
||||
- The privacy host remains limited to owner-only macOS privacy operations.
|
||||
|
||||
## Deliberately deferred
|
||||
|
||||
| Integration | Status | Boundary |
|
||||
|---|---|---|
|
||||
| **Apple Notes** | **Direct mutable Reyna CLI** | `reyna-cli notes` (and compatibility alias `macmini notes`) uses fixed JXA scripts through `/usr/bin/osascript`; note fields are JSON arguments, never script interpolation. This does not modify the stable Swift host, plist, entitlements, or signed bundle. It uses the existing Apple Events approval for the executing automation client; no native-host Notes socket operation exists. |
|
||||
| Apple Mail | Deferred / out of scope | Legacy Apple Mail automation remains unchanged; separate Thunderbird-local support is not a privacy-host migration. |
|
||||
| SpeechTranscriber live/file flows | Direct Reyna CLI execution | Migrated outside the native privacy host; live sessions remain local to the process/service hosting the CLI. |
|
||||
| Codex/Gemini image generation | Direct Reyna CLI execution | Migrated outside the native privacy host; external API/Codex credentials stay on the executing host. |
|
||||
|
||||
## Cutover rule
|
||||
|
||||
MacMiniMCP remains active. It may be retired only after a live caller inventory and validation matrix establish full replacement coverage and the owner explicitly approves cutover.
|
||||
@@ -0,0 +1,41 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ReynaCLIHost",
|
||||
platforms: [.macOS(.v13)],
|
||||
products: [
|
||||
.executable(name: "ReynaCLIHost", targets: ["ReynaCLIHost"]),
|
||||
.library(name: "ReynaCLIHostCore", targets: ["ReynaCLIHostCore"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CSignalSupport",
|
||||
path: "Sources/CSignalSupport",
|
||||
publicHeadersPath: "include"
|
||||
),
|
||||
.target(
|
||||
name: "ReynaCLIHostCore",
|
||||
dependencies: ["CSignalSupport"],
|
||||
path: "Sources/ReynaCLIHostCore",
|
||||
linkerSettings: [
|
||||
.linkedFramework("EventKit"),
|
||||
.linkedFramework("Contacts")
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "ReynaCLIHost",
|
||||
dependencies: ["ReynaCLIHostCore", "CSignalSupport"],
|
||||
path: "Sources/ReynaCLIHost",
|
||||
linkerSettings: [
|
||||
.linkedFramework("EventKit"),
|
||||
.linkedFramework("Contacts")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "ReynaCLIHostTests",
|
||||
dependencies: ["ReynaCLIHostCore"],
|
||||
path: "Tests/ReynaCLIHostTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,424 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
C00000000000000000000001 /* AppMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000001 /* AppMain.swift */; };
|
||||
C00000000000000000000002 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000004 /* AppEntry.swift */; };
|
||||
C00000000000000000000003 /* Protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000005 /* Protocol.swift */; };
|
||||
C00000000000000000000004 /* CalendarProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000006 /* CalendarProvider.swift */; };
|
||||
C00000000000000000000005 /* CalendarAuthorizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000007 /* CalendarAuthorizationProvider.swift */; };
|
||||
C00000000000000000000006 /* SocketPathValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000008 /* SocketPathValidation.swift */; };
|
||||
C00000000000000000000007 /* SocketServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000009 /* SocketServer.swift */; };
|
||||
C00000000000000000000008 /* CSignalSupport.c in Sources */ = {isa = PBXBuildFile; fileRef = B0000000000000000000000A /* CSignalSupport.c */; };
|
||||
C00000000000000000000009 /* EventKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B0000000000000000000000C /* EventKit.framework */; };
|
||||
C0000000000000000000000A /* ContactsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0000000000000000000000E /* ContactsProvider.swift */; };
|
||||
C0000000000000000000000B /* ContactsAuthorizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0000000000000000000000F /* ContactsAuthorizationProvider.swift */; };
|
||||
C0000000000000000000000C /* Contacts.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B00000000000000000000010 /* Contacts.framework */; };
|
||||
C0000000000000000000000D /* RemindersProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000011 /* RemindersProvider.swift */; };
|
||||
C0000000000000000000000E /* RemindersAuthorizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000012 /* RemindersAuthorizationProvider.swift */; };
|
||||
C00000000000000000000011 /* SystemInfoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000015 /* SystemInfoProvider.swift */; };
|
||||
C00000000000000000000012 /* PythonLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000016 /* PythonLauncher.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
B00000000000000000000001 /* AppMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMain.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B00000000000000000000003 /* ReynaCLIHost-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ReynaCLIHost-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
B00000000000000000000004 /* AppEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntry.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000005 /* Protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Protocol.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000006 /* CalendarProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000007 /* CalendarAuthorizationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarAuthorizationProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000008 /* SocketPathValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketPathValidation.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000009 /* SocketServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketServer.swift; sourceTree = "<group>"; };
|
||||
B0000000000000000000000A /* CSignalSupport.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = CSignalSupport.c; sourceTree = "<group>"; };
|
||||
B0000000000000000000000B /* CSignalSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CSignalSupport.h; sourceTree = "<group>"; };
|
||||
B0000000000000000000000C /* EventKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = EventKit.framework; path = System/Library/Frameworks/EventKit.framework; sourceTree = SDKROOT; };
|
||||
B0000000000000000000000E /* ContactsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsProvider.swift; sourceTree = "<group>"; };
|
||||
B0000000000000000000000F /* ContactsAuthorizationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsAuthorizationProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000010 /* Contacts.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Contacts.framework; path = System/Library/Frameworks/Contacts.framework; sourceTree = SDKROOT; };
|
||||
B00000000000000000000011 /* RemindersProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000012 /* RemindersAuthorizationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersAuthorizationProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000015 /* SystemInfoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfoProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000016 /* PythonLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PythonLauncher.swift; sourceTree = "<group>"; };
|
||||
B0000000000000000000000D /* Reyna CLI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Reyna CLI.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
D00000000000000000000002 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C00000000000000000000009 /* EventKit.framework in Frameworks */,
|
||||
C0000000000000000000000C /* Contacts.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
4CE47EC8302137F000015A48 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B0000000000000000000000C /* EventKit.framework */,
|
||||
B00000000000000000000010 /* Contacts.framework */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000002 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A00000000000000000000004 /* ReynaCLIHost */,
|
||||
A00000000000000000000005 /* Sources */,
|
||||
A00000000000000000000003 /* Products */,
|
||||
4CE47EC8302137F000015A48 /* Recovered References */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000003 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B0000000000000000000000D /* Reyna CLI.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000004 /* ReynaCLIHost */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B00000000000000000000001 /* AppMain.swift */,
|
||||
B00000000000000000000002 /* Info.plist */,
|
||||
B00000000000000000000003 /* ReynaCLIHost-Bridging-Header.h */,
|
||||
);
|
||||
path = ReynaCLIHost;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000005 /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A00000000000000000000006 /* ReynaCLIHostCore */,
|
||||
A00000000000000000000007 /* CSignalSupport */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000006 /* ReynaCLIHostCore */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B00000000000000000000004 /* AppEntry.swift */,
|
||||
B00000000000000000000005 /* Protocol.swift */,
|
||||
B00000000000000000000006 /* CalendarProvider.swift */,
|
||||
B00000000000000000000007 /* CalendarAuthorizationProvider.swift */,
|
||||
B0000000000000000000000E /* ContactsProvider.swift */,
|
||||
B0000000000000000000000F /* ContactsAuthorizationProvider.swift */,
|
||||
B00000000000000000000011 /* RemindersProvider.swift */,
|
||||
B00000000000000000000012 /* RemindersAuthorizationProvider.swift */,
|
||||
B00000000000000000000015 /* SystemInfoProvider.swift */,
|
||||
B00000000000000000000016 /* PythonLauncher.swift */,
|
||||
B00000000000000000000008 /* SocketPathValidation.swift */,
|
||||
B00000000000000000000009 /* SocketServer.swift */,
|
||||
);
|
||||
path = ReynaCLIHostCore;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000007 /* CSignalSupport */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B0000000000000000000000A /* CSignalSupport.c */,
|
||||
B0000000000000000000000B /* CSignalSupport.h */,
|
||||
);
|
||||
path = CSignalSupport;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
E00000000000000000000001 /* Reyna CLI */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = F00000000000000000000002 /* Build configuration list for PBXNativeTarget "Reyna CLI" */;
|
||||
buildPhases = (
|
||||
D00000000000000000000001 /* Sources */,
|
||||
D00000000000000000000002 /* Frameworks */,
|
||||
D00000000000000000000003 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = "Reyna CLI";
|
||||
productName = "Reyna CLI";
|
||||
productReference = B0000000000000000000000D /* Reyna CLI.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
E00000000000000000000002 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
E00000000000000000000001 = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = F00000000000000000000001 /* Build configuration list for PBXProject "ReynaCLIHost" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = A00000000000000000000002;
|
||||
productRefGroup = A00000000000000000000003 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
E00000000000000000000001 /* Reyna CLI */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
D00000000000000000000003 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
D00000000000000000000001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C00000000000000000000001 /* AppMain.swift in Sources */,
|
||||
C00000000000000000000002 /* AppEntry.swift in Sources */,
|
||||
C00000000000000000000003 /* Protocol.swift in Sources */,
|
||||
C00000000000000000000004 /* CalendarProvider.swift in Sources */,
|
||||
C00000000000000000000005 /* CalendarAuthorizationProvider.swift in Sources */,
|
||||
C0000000000000000000000A /* ContactsProvider.swift in Sources */,
|
||||
C0000000000000000000000B /* ContactsAuthorizationProvider.swift in Sources */,
|
||||
C0000000000000000000000D /* RemindersProvider.swift in Sources */,
|
||||
C0000000000000000000000E /* RemindersAuthorizationProvider.swift in Sources */,
|
||||
C00000000000000000000011 /* SystemInfoProvider.swift in Sources */,
|
||||
C00000000000000000000012 /* PythonLauncher.swift in Sources */,
|
||||
C00000000000000000000006 /* SocketPathValidation.swift in Sources */,
|
||||
C00000000000000000000007 /* SocketServer.swift in Sources */,
|
||||
C00000000000000000000008 /* CSignalSupport.c in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
F00000000000000000000003 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F00000000000000000000004 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
F00000000000000000000005 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = RHUM5U925W;
|
||||
EXECUTABLE_NAME = ReynaCLIHost;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(SRCROOT)/Sources/CSignalSupport/include",
|
||||
);
|
||||
INFOPLIST_FILE = ReynaCLIHost/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Reyna CLI";
|
||||
INFOPLIST_KEY_LSUIElement = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.reyna.cli.privacy-host";
|
||||
PRODUCT_NAME = "Reyna CLI";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "ReynaCLIHost/ReynaCLIHost-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F00000000000000000000006 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = RHUM5U925W;
|
||||
EXECUTABLE_NAME = ReynaCLIHost;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(SRCROOT)/Sources/CSignalSupport/include",
|
||||
);
|
||||
INFOPLIST_FILE = ReynaCLIHost/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Reyna CLI";
|
||||
INFOPLIST_KEY_LSUIElement = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.reyna.cli.privacy-host";
|
||||
PRODUCT_NAME = "Reyna CLI";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "ReynaCLIHost/ReynaCLIHost-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
F00000000000000000000001 /* Build configuration list for PBXProject "ReynaCLIHost" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F00000000000000000000003 /* Debug */,
|
||||
F00000000000000000000004 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
F00000000000000000000002 /* Build configuration list for PBXNativeTarget "Reyna CLI" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F00000000000000000000005 /* Debug */,
|
||||
F00000000000000000000006 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = E00000000000000000000002 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E00000000000000000000001"
|
||||
BuildableName = "Reyna CLI.app"
|
||||
BlueprintName = "Reyna CLI"
|
||||
ReferencedContainer = "container:ReynaCLIHost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E00000000000000000000001"
|
||||
BuildableName = "Reyna CLI.app"
|
||||
BlueprintName = "Reyna CLI"
|
||||
ReferencedContainer = "container:ReynaCLIHost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E00000000000000000000001"
|
||||
BuildableName = "Reyna CLI.app"
|
||||
BlueprintName = "Reyna CLI"
|
||||
ReferencedContainer = "container:ReynaCLIHost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
// Xcode-owned macOS app entry — app-bundle-associated executable so TCC sees signed bundle identity.
|
||||
// Headless entry point preserves --socket and stdin JSON-lines modes.
|
||||
|
||||
@main
|
||||
struct ReynaCLIApp {
|
||||
static func main() {
|
||||
runReynaCLIHost(arguments: CommandLine.arguments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PLIST-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Reyna CLI</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ReynaCLIHost</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.reyna.cli.privacy-host</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Reyna CLI</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>Reyna CLI needs calendar access to list and manage your events locally.</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>Reyna CLI needs contacts access to search and manage your contacts locally.</string>
|
||||
<key>NSRemindersFullAccessUsageDescription</key>
|
||||
<string>Reyna CLI needs reminders access to list and manage your reminders locally.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "CSignalSupport.h"
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "CSignalSupport.h"
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <signal.h>
|
||||
#include <stddef.h>
|
||||
|
||||
static char g_socket_path[104 * 4];
|
||||
static volatile sig_atomic_t g_has_path = 0;
|
||||
|
||||
void reyna_store_socket_path(const char *path) {
|
||||
if (!path) {
|
||||
g_socket_path[0] = '\0';
|
||||
g_has_path = 0;
|
||||
return;
|
||||
}
|
||||
strncpy(g_socket_path, path, sizeof(g_socket_path)-1);
|
||||
g_socket_path[sizeof(g_socket_path)-1] = '\0';
|
||||
g_has_path = 1;
|
||||
}
|
||||
|
||||
void reyna_cleanup_socket_sync(void) {
|
||||
if (!g_has_path) return;
|
||||
if (g_socket_path[0] == '\0') return;
|
||||
unlink(g_socket_path);
|
||||
}
|
||||
|
||||
static void reyna_signal_handler(int sig) {
|
||||
(void)sig;
|
||||
reyna_cleanup_socket_sync();
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
void reyna_install_signal_handlers(void) {
|
||||
struct sigaction sa;
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = reyna_signal_handler;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = 0;
|
||||
sigaction(SIGTERM, &sa, NULL);
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
sigaction(SIGHUP, &sa, NULL);
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#ifndef CSignalSupport_h
|
||||
#define CSignalSupport_h
|
||||
#include <sys/types.h>
|
||||
|
||||
void reyna_store_socket_path(const char *path);
|
||||
void reyna_install_signal_handlers(void);
|
||||
void reyna_cleanup_socket_sync(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,4 @@
|
||||
module CSignalSupport {
|
||||
header "CSignalSupport.h"
|
||||
export *
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
// Thin executable wrapper preserving AF_UNIX protocol — all logic lives in ReynaCLIHostCore shared library.
|
||||
// This file is the source of truth for the SwiftPM binary AND referenced by the Xcode app target's main.
|
||||
|
||||
runReynaCLIHost(arguments: CommandLine.arguments)
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
// Public entry point used by both SwiftPM executable and Xcode app target.
|
||||
// Preserves AF_UNIX privacy-host protocol exactly as before.
|
||||
// Headless design: socket-server or stdin JSON-lines mode only.
|
||||
|
||||
public func runReynaCLIHost(arguments: [String] = CommandLine.arguments) -> Never {
|
||||
let hasSocket = arguments.contains("--socket")
|
||||
let hasPython = arguments.contains("--python")
|
||||
|
||||
if hasSocket && hasPython {
|
||||
fputs("error: --python cannot be combined with --socket\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
|
||||
if let idx = arguments.firstIndex(of: "--python") {
|
||||
let forwardedArguments = Array(arguments.dropFirst(idx + 1))
|
||||
Darwin.exit(runPythonCLI(forwardedArguments: forwardedArguments))
|
||||
}
|
||||
|
||||
if let idx = arguments.firstIndex(of: "--socket") {
|
||||
let nextIdx = idx + 1
|
||||
guard nextIdx < arguments.count else {
|
||||
fputs("error: --socket requires a path argument\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
let socketPath = arguments[nextIdx]
|
||||
runSocketServer(socketPath: socketPath)
|
||||
} else {
|
||||
runStdinLoop()
|
||||
}
|
||||
}
|
||||
|
||||
func extractRecoverableId(from data: Data) -> String? {
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let id = obj["id"] as? String {
|
||||
return id
|
||||
}
|
||||
guard let str = String(data: data, encoding: .utf8) else { return nil }
|
||||
let pattern = "\"id\"\\s*:\\s*\"([^\"]*)\""
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []),
|
||||
let match = regex.firstMatch(in: str, options: [], range: NSRange(str.startIndex..., in: str)),
|
||||
match.numberOfRanges >= 2,
|
||||
let r = Range(match.range(at: 1), in: str) else {
|
||||
return nil
|
||||
}
|
||||
return String(str[r])
|
||||
}
|
||||
|
||||
func writeResponse(_ response: Response) {
|
||||
guard let jsonData = try? JSONEncoder().encode(response),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8),
|
||||
let outData = (jsonString + "\n").data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
FileHandle.standardOutput.write(outData)
|
||||
}
|
||||
|
||||
func handleLine(_ lineData: Data) {
|
||||
if lineData.isEmpty { return }
|
||||
if let s = String(data: lineData, encoding: .utf8),
|
||||
s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return
|
||||
}
|
||||
if let request = try? JSONDecoder().decode(Request.self, from: lineData) {
|
||||
let response = dispatch(request: request)
|
||||
writeResponse(response)
|
||||
} else {
|
||||
let recoveredId = extractRecoverableId(from: lineData)
|
||||
let err = ErrorPayload(code: "invalid_request", message: "Invalid request JSON")
|
||||
let resp = Response(id: recoveredId ?? "", ok: false, result: nil, error: err)
|
||||
writeResponse(resp)
|
||||
}
|
||||
}
|
||||
|
||||
func runStdinLoop() -> Never {
|
||||
while let line = readLine() {
|
||||
if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue }
|
||||
if let data = line.data(using: .utf8) {
|
||||
handleLine(data)
|
||||
} else {
|
||||
let err = ErrorPayload(code: "invalid_request", message: "Invalid request encoding")
|
||||
let resp = Response(id: "", ok: false, result: nil, error: err)
|
||||
writeResponse(resp)
|
||||
}
|
||||
}
|
||||
Darwin.exit(0)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// Explicit calendar authorization provider – ONLY location allowed to call requestFullAccessToEvents.
|
||||
// Production list path (EventKitCalendarProvider) must remain read-only and never prompt.
|
||||
|
||||
// Public auth status mirrored from EKAuthorizationStatus without importing EventKit into protocol file
|
||||
enum CalendarAuthorizationStatus: String, Equatable, Sendable {
|
||||
case authorized
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case writeOnly
|
||||
case unknown
|
||||
}
|
||||
|
||||
protocol CalendarAuthorizationProviding: Sendable {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus
|
||||
func requestFullAccess() throws -> Bool
|
||||
}
|
||||
|
||||
// Internal main-run-loop pumping bridge – bounded wait that pumps run loop instead of blocking it.
|
||||
// Provides deterministic seam via injected starter closure.
|
||||
struct EventKitMainRunLoopBridge: Sendable {
|
||||
typealias Starter = @Sendable (@escaping @Sendable (Bool, Error?) -> Void) -> Void
|
||||
|
||||
/// Waits up to `timeout` seconds for `starter` to invoke completion.
|
||||
/// The starter is expected to eventually call completion, potentially from main run loop.
|
||||
/// This method pumps the current run loop (which is the main run loop when called on main thread)
|
||||
/// so that EventKit's main-run-loop delivered completion can run instead of deadlocking.
|
||||
func requestAccess(timeout: TimeInterval = 30, starter: @escaping Starter) throws -> Bool {
|
||||
final class Box: @unchecked Sendable {
|
||||
var granted: Bool = false
|
||||
var error: Error? = nil
|
||||
var done: Bool = false
|
||||
let lock = NSLock()
|
||||
func setOnce(granted: Bool, error: Error?) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !done else { return false }
|
||||
self.granted = granted
|
||||
self.error = error
|
||||
self.done = true
|
||||
return true
|
||||
}
|
||||
func snapshot() -> (done: Bool, granted: Bool, error: Error?) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (done, granted, error)
|
||||
}
|
||||
}
|
||||
|
||||
let box = Box()
|
||||
let completion: @Sendable (Bool, Error?) -> Void = { granted, error in
|
||||
_ = box.setOnce(granted: granted, error: error)
|
||||
}
|
||||
|
||||
// Trigger the underlying async request
|
||||
starter(completion)
|
||||
|
||||
let deadline = Date(timeIntervalSinceNow: timeout)
|
||||
// Pump run loop until done or timeout. Uses RunLoop.current.run(mode:before:) to avoid busy spin.
|
||||
while true {
|
||||
let snap = box.snapshot()
|
||||
if snap.done { break }
|
||||
if Date() >= deadline { break }
|
||||
// 20ms slice – small enough to be responsive, large enough to avoid spin
|
||||
let next = Date(timeIntervalSinceNow: 0.02)
|
||||
_ = RunLoop.current.run(mode: .default, before: next)
|
||||
}
|
||||
|
||||
let final = box.snapshot()
|
||||
if !final.done {
|
||||
throw CalendarProviderError.unavailable("calendar authorization timed out")
|
||||
}
|
||||
if let err = final.error {
|
||||
throw CalendarProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
return final.granted
|
||||
}
|
||||
}
|
||||
|
||||
struct EventKitCalendarAuthorizationProvider: CalendarAuthorizationProviding {
|
||||
// Allow injection of bridge for tests while keeping default production behavior
|
||||
var bridge: EventKitMainRunLoopBridge = EventKitMainRunLoopBridge()
|
||||
|
||||
// Convert EK status to our enum
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus {
|
||||
let s = EKEventStore.authorizationStatus(for: .event)
|
||||
switch s {
|
||||
case .fullAccess, .authorized:
|
||||
return .authorized
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
case .writeOnly:
|
||||
return .writeOnly
|
||||
@unknown default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded async-to-sync bridge, max 30s, pumping main run loop. ONLY place calling requestFullAccessToEvents.
|
||||
func requestFullAccess() throws -> Bool {
|
||||
if #available(macOS 14.0, *) {
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToEvents { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw CalendarProviderError.unavailable("requestFullAccessToEvents requires macOS 14+")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// Production EventKit provider – read-only for list, mutating only for create.
|
||||
// Requirements:
|
||||
// - Only checks authorization status (never triggers prompt) for list/create.
|
||||
// - Lists calendars when status permits.
|
||||
// - Never calls prompting APIs except in auth provider.
|
||||
// - If permission absent/denied/restricted, throw permissionRequired.
|
||||
|
||||
struct EventKitCalendarProvider: CalendarListProviding, CalendarEventsListProviding, CalendarEventCreateProviding {
|
||||
|
||||
// MARK: - Auth check shared
|
||||
private func requireAuthorizedOrThrow() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
break
|
||||
case .writeOnly:
|
||||
// writeOnly does not allow reading calendars/events; but for create we could allow? Task says already-authorized access but no permission request; map denied/not-determined to permission_required for all.
|
||||
// Simpler: for events list, writeOnly -> permissionRequired; for create, writeOnly should also require check but writeOnly actually allows writing. However to keep deterministic, attempt to respect writeOnly for create?
|
||||
// Spec: map denied/not-determined to permission_required, provider failures to calendar_unavailable.
|
||||
// To be safe: for list -> permissionRequired, for create we will check below differently? But shared throw would block create with writeOnly unnecessarily.
|
||||
// We differentiate inside methods. For this helper, allow writeOnly as authorized for mutating path? Caller should call specific check.
|
||||
throw CalendarProviderError.permissionRequired
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: Bool = false) throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
return
|
||||
case .writeOnly:
|
||||
if allowsWriteOnlyRead {
|
||||
return
|
||||
}
|
||||
// For read paths (list calendars, list events), writeOnly does NOT permit read -> permission_required
|
||||
throw CalendarProviderError.permissionRequired
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar list (existing)
|
||||
func listCalendars() throws -> [CalendarListItem] {
|
||||
try requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: false)
|
||||
|
||||
let store = EKEventStore()
|
||||
let ekCalendars = store.calendars(for: .event)
|
||||
|
||||
let items: [CalendarListItem] = ekCalendars.map { cal in
|
||||
let sourceTitle = cal.source.title
|
||||
let typeString: String
|
||||
switch cal.type {
|
||||
case .birthday:
|
||||
typeString = "birthday"
|
||||
case .calDAV:
|
||||
typeString = "caldav"
|
||||
case .exchange:
|
||||
typeString = "exchange"
|
||||
case .local:
|
||||
typeString = "local"
|
||||
case .subscription:
|
||||
typeString = "subscription"
|
||||
@unknown default:
|
||||
typeString = "unknown"
|
||||
}
|
||||
return CalendarListItem(
|
||||
id: cal.calendarIdentifier,
|
||||
title: cal.title,
|
||||
source: sourceTitle,
|
||||
type: typeString
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Calendar helpers for events
|
||||
private func allCalendars(from store: EKEventStore) -> [EKCalendar] {
|
||||
store.calendars(for: .event)
|
||||
}
|
||||
|
||||
private func resolveCalendarForList(store: EKEventStore, calendarId: String?, calendarTitle: String?) throws -> [EKCalendar] {
|
||||
// Return array of matching calendars (filtered)
|
||||
// Rules:
|
||||
// - if calendarId provided (stable ID), exact match only one; if unknown -> invalidRequest
|
||||
// - else if calendarTitle provided, exact title match; must be unique else invalidRequest (ambiguous) or unknown => invalidRequest
|
||||
// - else all calendars
|
||||
let calendars = allCalendars(from: store)
|
||||
|
||||
if let cid = calendarId, !cid.isEmpty {
|
||||
// ID wins
|
||||
if let found = calendars.first(where: { $0.calendarIdentifier == cid }) {
|
||||
return [found]
|
||||
} else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
|
||||
}
|
||||
}
|
||||
|
||||
if let title = calendarTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// no filter -> all
|
||||
return calendars
|
||||
}
|
||||
|
||||
private func resolveCalendarForCreate(store: EKEventStore, calendarId: String?, calendarTitle: String?) throws -> EKCalendar {
|
||||
// Create must not default to arbitrary.
|
||||
// If neither id nor title -> invalidRequest
|
||||
let calendars = allCalendars(from: store)
|
||||
|
||||
if let cid = calendarId, !cid.isEmpty {
|
||||
guard let found = calendars.first(where: { $0.calendarIdentifier == cid }) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
|
||||
}
|
||||
if !found.allowsContentModifications {
|
||||
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(found.title)")
|
||||
}
|
||||
// also check writable via allowsContentModifications; isImmutable also relevant but we use allowsContentModifications
|
||||
return found
|
||||
}
|
||||
|
||||
if let title = calendarTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
|
||||
}
|
||||
let found = matched[0]
|
||||
if !found.allowsContentModifications {
|
||||
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// No calendar specified – per spec "no default first arbitrary calendar"
|
||||
throw CalendarProviderError.invalidRequest("Calendar must be specified by id or exact unique title")
|
||||
}
|
||||
|
||||
// MARK: - Events list
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
// Permission: read requires full access
|
||||
try requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: false)
|
||||
|
||||
let store = EKEventStore()
|
||||
let targetCalendars: [EKCalendar]
|
||||
do {
|
||||
targetCalendars = try resolveCalendarForList(store: store, calendarId: calendarId, calendarTitle: calendarTitle)
|
||||
} catch let err as CalendarProviderError {
|
||||
throw err
|
||||
} catch {
|
||||
throw CalendarProviderError.unavailable("Calendar lookup failed")
|
||||
}
|
||||
|
||||
if targetCalendars.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
// EK predicate
|
||||
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: targetCalendars)
|
||||
let ekEvents: [EKEvent] = store.events(matching: predicate)
|
||||
|
||||
// Map and sort deterministically, enforce overlap check (predicate already does but ensure)
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
|
||||
var items: [CalendarEventItem] = []
|
||||
items.reserveCapacity(min(ekEvents.count, limit))
|
||||
|
||||
for ev in ekEvents {
|
||||
guard let evStart = ev.startDate, let evEnd = ev.endDate else { continue }
|
||||
// Enforce overlap (EventKit predicate should already overlap but safe)
|
||||
if evEnd < start || evStart > end { continue }
|
||||
|
||||
guard let cal = ev.calendar else { continue }
|
||||
let item = CalendarEventItem(
|
||||
id: ev.eventIdentifier ?? ev.calendarItemIdentifier,
|
||||
title: ev.title ?? "",
|
||||
start: iso.string(from: evStart),
|
||||
end: iso.string(from: evEnd),
|
||||
all_day: ev.isAllDay,
|
||||
calendar_id: cal.calendarIdentifier,
|
||||
calendar_title: cal.title,
|
||||
notes: ev.notes,
|
||||
location: ev.location
|
||||
)
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
// Deterministic sort by start, then title, then id, then truncate to limit
|
||||
items.sort {
|
||||
if $0.start != $1.start { return $0.start < $1.start }
|
||||
if $0.title != $1.title { return $0.title < $1.title }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
|
||||
if items.count > limit {
|
||||
return Array(items.prefix(limit))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Event create
|
||||
func createEvent(
|
||||
title: String,
|
||||
start: Date,
|
||||
end: Date,
|
||||
allDay: Bool,
|
||||
notes: String?,
|
||||
location: String?,
|
||||
calendarId: String?,
|
||||
calendarTitle: String?
|
||||
) throws -> CalendarEventItem {
|
||||
// For create, we allow fullAccess, authorized, and writeOnly (since writeOnly permits creation)
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
switch status {
|
||||
case .fullAccess, .authorized, .writeOnly:
|
||||
break
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
|
||||
let destination: EKCalendar
|
||||
do {
|
||||
destination = try resolveCalendarForCreate(store: store, calendarId: calendarId, calendarTitle: calendarTitle)
|
||||
} catch let err as CalendarProviderError {
|
||||
throw err
|
||||
} catch {
|
||||
throw CalendarProviderError.unavailable("Calendar lookup failed")
|
||||
}
|
||||
|
||||
// Validate start < end already done in dispatch, but double check
|
||||
if start >= end {
|
||||
throw CalendarProviderError.invalidRequest("start must occur before end")
|
||||
}
|
||||
|
||||
let ekEvent = EKEvent(eventStore: store)
|
||||
ekEvent.title = title
|
||||
ekEvent.startDate = start
|
||||
ekEvent.endDate = end
|
||||
ekEvent.isAllDay = allDay
|
||||
ekEvent.notes = notes
|
||||
ekEvent.location = location
|
||||
ekEvent.calendar = destination
|
||||
|
||||
do {
|
||||
try store.save(ekEvent, span: .thisEvent, commit: true)
|
||||
} catch {
|
||||
throw CalendarProviderError.unavailable("Failed to save event: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
|
||||
return CalendarEventItem(
|
||||
id: ekEvent.eventIdentifier ?? ekEvent.calendarItemIdentifier,
|
||||
title: ekEvent.title ?? title,
|
||||
start: iso.string(from: ekEvent.startDate ?? start),
|
||||
end: iso.string(from: ekEvent.endDate ?? end),
|
||||
all_day: ekEvent.isAllDay,
|
||||
calendar_id: destination.calendarIdentifier,
|
||||
calendar_title: destination.title,
|
||||
notes: ekEvent.notes,
|
||||
location: ekEvent.location
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Contacts
|
||||
|
||||
// Explicit Contacts authorization provider – ONLY location allowed to call requestAccess(for:)
|
||||
enum ContactsBridgingError: Error, Equatable, Sendable {
|
||||
case timeout
|
||||
case unavailable(String)
|
||||
}
|
||||
|
||||
struct ContactsMainRunLoopBridge: Sendable {
|
||||
typealias Starter = @Sendable (@escaping @Sendable (Bool, Error?) -> Void) -> Void
|
||||
|
||||
func requestAccess(timeout: TimeInterval = 30, starter: @escaping Starter) throws -> Bool {
|
||||
final class Box: @unchecked Sendable {
|
||||
var granted: Bool = false
|
||||
var error: Error? = nil
|
||||
var done: Bool = false
|
||||
let lock = NSLock()
|
||||
func setOnce(granted: Bool, error: Error?) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !done else { return false }
|
||||
self.granted = granted
|
||||
self.error = error
|
||||
self.done = true
|
||||
return true
|
||||
}
|
||||
func snapshot() -> (done: Bool, granted: Bool, error: Error?) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (done, granted, error)
|
||||
}
|
||||
}
|
||||
|
||||
let box = Box()
|
||||
let completion: @Sendable (Bool, Error?) -> Void = { granted, error in
|
||||
_ = box.setOnce(granted: granted, error: error)
|
||||
}
|
||||
|
||||
starter(completion)
|
||||
|
||||
let deadline = Date(timeIntervalSinceNow: timeout)
|
||||
while true {
|
||||
let snap = box.snapshot()
|
||||
if snap.done { break }
|
||||
if Date() >= deadline { break }
|
||||
let next = Date(timeIntervalSinceNow: 0.02)
|
||||
_ = RunLoop.current.run(mode: .default, before: next)
|
||||
}
|
||||
|
||||
let final = box.snapshot()
|
||||
if !final.done {
|
||||
throw ContactsProviderError.unavailable("contacts authorization timed out")
|
||||
}
|
||||
if let err = final.error {
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
return final.granted
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsAuthorizationProvider: ContactsAuthorizationProviding {
|
||||
var bridge: ContactsMainRunLoopBridge = ContactsMainRunLoopBridge()
|
||||
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus {
|
||||
let s = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch s {
|
||||
case .authorized:
|
||||
return .authorized
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
@unknown default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
func requestAccess() throws -> Bool {
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = CNContactStore()
|
||||
store.requestAccess(for: .contacts) { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import Foundation
|
||||
import Contacts
|
||||
|
||||
// MARK: - Contacts data models
|
||||
struct ContactListItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let organization: String
|
||||
let modifiedAt: String
|
||||
}
|
||||
|
||||
struct ContactEmailLabelValue: Codable, Equatable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
}
|
||||
|
||||
struct ContactPhoneLabelValue: Codable, Equatable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
}
|
||||
|
||||
struct ContactDetailItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let firstName: String
|
||||
let lastName: String
|
||||
let organization: String
|
||||
let jobTitle: String
|
||||
let emails: [ContactEmailLabelValue]
|
||||
let phones: [ContactPhoneLabelValue]
|
||||
let modifiedAt: String
|
||||
}
|
||||
|
||||
struct ContactCreateResult: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let organization: String
|
||||
}
|
||||
|
||||
// MARK: - Contacts provider errors
|
||||
enum ContactsProviderError: Error, Equatable, Sendable {
|
||||
case permissionRequired
|
||||
case permissionDenied
|
||||
case unavailable(String)
|
||||
case invalidRequest(String)
|
||||
case notFound(String)
|
||||
}
|
||||
|
||||
// MARK: - Contact provider protocols
|
||||
protocol ContactsAuthorizationProviding: Sendable {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus
|
||||
func requestAccess() throws -> Bool
|
||||
}
|
||||
|
||||
enum ContactsAuthorizationStatus: String, Equatable, Sendable {
|
||||
case authorized
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case unknown
|
||||
}
|
||||
|
||||
protocol ContactsSearchProviding: Sendable {
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem]
|
||||
}
|
||||
|
||||
protocol ContactsReadProviding: Sendable {
|
||||
func readContact(id: String) throws -> ContactDetailItem
|
||||
}
|
||||
|
||||
protocol ContactsCreateProviding: Sendable {
|
||||
func createContact(
|
||||
firstName: String?,
|
||||
lastName: String?,
|
||||
organization: String?,
|
||||
jobTitle: String?,
|
||||
note: String?,
|
||||
email: ContactEmailLabelValue?,
|
||||
phone: ContactPhoneLabelValue?
|
||||
) throws -> ContactCreateResult
|
||||
}
|
||||
|
||||
protocol FullContactsProviding: ContactsSearchProviding, ContactsReadProviding, ContactsCreateProviding {}
|
||||
|
||||
// MARK: - Production Contacts providers
|
||||
|
||||
private func displayNameFromFetchedParts(givenName: String, familyName: String) -> String {
|
||||
let combined = "\(givenName) \(familyName)".trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let squashed = combined.components(separatedBy: .whitespaces).filter { !$0.isEmpty }.joined(separator: " ")
|
||||
return squashed
|
||||
}
|
||||
|
||||
struct ContactsSearchProvider: ContactsSearchProviding {
|
||||
private func requireAuthorized() throws {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] {
|
||||
try requireAuthorized()
|
||||
let store = CNContactStore()
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactIdentifierKey as CNKeyDescriptor,
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor
|
||||
]
|
||||
let fetchRequest = CNContactFetchRequest(keysToFetch: keys)
|
||||
var items: [ContactListItem] = []
|
||||
let q = query?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let shouldFilter = !(q?.isEmpty ?? true)
|
||||
|
||||
do {
|
||||
try store.enumerateContacts(with: fetchRequest) { contact, stop in
|
||||
// Production crash fix: the formatter accesses unfetched
|
||||
// properties like middleName (CNPropertyNotFetchedException is ObjC exception
|
||||
// and is uncatchable in Swift). Construct deterministically from only fetched keys.
|
||||
let fullName = displayNameFromFetchedParts(givenName: contact.givenName, familyName: contact.familyName)
|
||||
let org = contact.organizationName
|
||||
if shouldFilter, let lowerQ = q {
|
||||
let haystack = "\(fullName)\n\(org)".lowercased()
|
||||
if !haystack.contains(lowerQ) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Deterministic: do not leak current time; nil modification date yields stable empty string
|
||||
let modifiedStr: String = ""
|
||||
items.append(ContactListItem(
|
||||
id: contact.identifier,
|
||||
name: fullName,
|
||||
organization: org,
|
||||
modifiedAt: modifiedStr
|
||||
))
|
||||
if items.count >= limit {
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
} catch let err as NSError {
|
||||
// Permission or other failure
|
||||
if err.domain == CNErrorDomain {
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
|
||||
// Deterministic: sorted by name then org then id
|
||||
items.sort {
|
||||
if $0.name != $1.name { return $0.name < $1.name }
|
||||
if $0.organization != $1.organization { return $0.organization < $1.organization }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
if items.count > limit {
|
||||
return Array(items.prefix(limit))
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsReadProvider: ContactsReadProviding {
|
||||
private func requireAuthorized() throws {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
func readContact(id: String) throws -> ContactDetailItem {
|
||||
guard !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw ContactsProviderError.invalidRequest("Missing required argument: id")
|
||||
}
|
||||
try requireAuthorized()
|
||||
let store = CNContactStore()
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactIdentifierKey as CNKeyDescriptor,
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactJobTitleKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor
|
||||
]
|
||||
do {
|
||||
let contact = try store.unifiedContact(withIdentifier: id, keysToFetch: keys)
|
||||
// Same crash root cause as search: the system formatter can touch unfetched keys.
|
||||
// Use only the keys we fetched to avoid ObjC CNPropertyNotFetchedException.
|
||||
let fullName = displayNameFromFetchedParts(givenName: contact.givenName, familyName: contact.familyName)
|
||||
// Deterministic: nil modification date yields stable empty string, not current time
|
||||
let modifiedStr = ""
|
||||
|
||||
let emails = contact.emailAddresses.map { labeled in
|
||||
ContactEmailLabelValue(
|
||||
label: CNLabeledValue<NSString>.localizedString(forLabel: labeled.label ?? ""),
|
||||
value: labeled.value as String
|
||||
)
|
||||
}
|
||||
let phones = contact.phoneNumbers.map { labeled in
|
||||
ContactPhoneLabelValue(
|
||||
label: CNLabeledValue<CNPhoneNumber>.localizedString(forLabel: labeled.label ?? ""),
|
||||
value: labeled.value.stringValue
|
||||
)
|
||||
}
|
||||
return ContactDetailItem(
|
||||
id: contact.identifier,
|
||||
name: fullName,
|
||||
firstName: contact.givenName,
|
||||
lastName: contact.familyName,
|
||||
organization: contact.organizationName,
|
||||
jobTitle: contact.jobTitle,
|
||||
emails: emails,
|
||||
phones: phones,
|
||||
modifiedAt: modifiedStr
|
||||
)
|
||||
} catch let err as CNError {
|
||||
if err.code == .recordDoesNotExist {
|
||||
throw ContactsProviderError.notFound("Contact not found: \(id)")
|
||||
}
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
} catch let err as ContactsProviderError {
|
||||
throw err
|
||||
} catch {
|
||||
throw ContactsProviderError.unavailable(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsCreateProvider: ContactsCreateProviding {
|
||||
private func requireAuthorized() throws {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw ContactsProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
func createContact(
|
||||
firstName: String?,
|
||||
lastName: String?,
|
||||
organization: String?,
|
||||
jobTitle: String?,
|
||||
note: String?,
|
||||
email: ContactEmailLabelValue?,
|
||||
phone: ContactPhoneLabelValue?
|
||||
) throws -> ContactCreateResult {
|
||||
try requireAuthorized()
|
||||
let mutable = CNMutableContact()
|
||||
mutable.givenName = firstName ?? ""
|
||||
mutable.familyName = lastName ?? ""
|
||||
mutable.organizationName = organization ?? ""
|
||||
mutable.jobTitle = jobTitle ?? ""
|
||||
if let n = note {
|
||||
mutable.note = n
|
||||
}
|
||||
if let em = email {
|
||||
mutable.emailAddresses = [CNLabeledValue(label: em.label.isEmpty ? CNLabelWork : em.label, value: em.value as NSString)]
|
||||
}
|
||||
if let ph = phone {
|
||||
mutable.phoneNumbers = [CNLabeledValue(label: ph.label.isEmpty ? CNLabelPhoneNumberMobile : ph.label, value: CNPhoneNumber(stringValue: ph.value))]
|
||||
}
|
||||
let store = CNContactStore()
|
||||
let saveRequest = CNSaveRequest()
|
||||
saveRequest.add(mutable, toContainerWithIdentifier: nil)
|
||||
do {
|
||||
try store.execute(saveRequest)
|
||||
} catch let err as NSError {
|
||||
throw ContactsProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
// CNMutableContact doesn't trigger key-fetch checks; safe deterministic construction still.
|
||||
// Prefer only locally available strings – avoids future formatter regressions.
|
||||
let fullName = displayNameFromFetchedParts(givenName: mutable.givenName, familyName: mutable.familyName)
|
||||
return ContactCreateResult(
|
||||
id: mutable.identifier,
|
||||
name: fullName,
|
||||
organization: organization ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
public struct PythonRuntime: Equatable {
|
||||
public let workingDirectory: URL
|
||||
public let python: URL
|
||||
}
|
||||
|
||||
public func parsePythonRuntimeConfig(_ content: String) -> [String: String] {
|
||||
var values: [String: String] = [:]
|
||||
for rawLine in content.components(separatedBy: .newlines) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !line.isEmpty, !line.hasPrefix("#") else { continue }
|
||||
let parts = line.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard parts.count == 2 else { continue }
|
||||
let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.count >= 2,
|
||||
((value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'"))) {
|
||||
value.removeFirst()
|
||||
value.removeLast()
|
||||
}
|
||||
guard key == "REYNA_CLI_DIR" || key == "REYNA_CLI_PYTHON" else { continue }
|
||||
values[key] = value
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
public func pythonRuntimeConfig(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> [String: String] {
|
||||
let paths = [
|
||||
homeDirectory.appendingPathComponent(".reyna-cli.env"),
|
||||
homeDirectory.appendingPathComponent(".config/reyna-cli/env"),
|
||||
]
|
||||
var result: [String: String] = [:]
|
||||
for path in paths {
|
||||
guard let content = try? String(contentsOf: path, encoding: .utf8) else { continue }
|
||||
result.merge(parsePythonRuntimeConfig(content)) { _, newest in newest }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public func resolvePythonRuntime(
|
||||
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
config: [String: String]? = nil,
|
||||
isExecutable: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
) -> PythonRuntime {
|
||||
let loadedConfig = config ?? pythonRuntimeConfig(homeDirectory: homeDirectory)
|
||||
let defaultWorkDirectory = homeDirectory.appendingPathComponent("Projects/reyna-cli").path
|
||||
let workDirectoryPath = environment["REYNA_CLI_DIR"] ?? loadedConfig["REYNA_CLI_DIR"] ?? defaultWorkDirectory
|
||||
let selectedPython = environment["REYNA_CLI_PYTHON"]
|
||||
?? loadedConfig["REYNA_CLI_PYTHON"]
|
||||
?? URL(fileURLWithPath: workDirectoryPath).appendingPathComponent(".venv/bin/python").path
|
||||
let pythonPath = isExecutable(selectedPython) ? selectedPython : "/usr/bin/python3"
|
||||
return PythonRuntime(
|
||||
workingDirectory: URL(fileURLWithPath: workDirectoryPath),
|
||||
python: URL(fileURLWithPath: pythonPath)
|
||||
)
|
||||
}
|
||||
|
||||
public func pythonCommand(runtime: PythonRuntime, forwardedArguments: [String]) -> [String] {
|
||||
[runtime.python.path, "-m", "reyna_cli.cli"] + forwardedArguments
|
||||
}
|
||||
|
||||
public func pythonLaunchEnvironment(from environment: [String: String] = ProcessInfo.processInfo.environment) -> [String: String] {
|
||||
var sanitized = environment
|
||||
for key in ["PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV"] {
|
||||
sanitized.removeValue(forKey: key)
|
||||
}
|
||||
sanitized["PYTHONNOUSERSITE"] = "1"
|
||||
return sanitized
|
||||
}
|
||||
|
||||
public func runPythonCLI(forwardedArguments: [String]) -> Int32 {
|
||||
let runtime = resolvePythonRuntime()
|
||||
let command = pythonCommand(runtime: runtime, forwardedArguments: forwardedArguments)
|
||||
let process = Process()
|
||||
process.executableURL = runtime.python
|
||||
process.arguments = Array(command.dropFirst())
|
||||
process.currentDirectoryURL = runtime.workingDirectory
|
||||
process.environment = pythonLaunchEnvironment()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus
|
||||
} catch {
|
||||
fputs("error: failed to run Reyna CLI Python: \(error)\n", stderr)
|
||||
return 127
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// ONLY place allowed to call requestFullAccessToReminders
|
||||
enum RemindersAuthorizationStatus: String, Equatable, Sendable {
|
||||
case authorized
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case writeOnly
|
||||
case unknown
|
||||
}
|
||||
|
||||
protocol RemindersAuthorizationProviding: Sendable {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus
|
||||
func requestFullAccess() throws -> Bool
|
||||
}
|
||||
|
||||
struct RemindersMainRunLoopBridge: Sendable {
|
||||
typealias Starter = @Sendable (@escaping @Sendable (Bool, Error?) -> Void) -> Void
|
||||
|
||||
func requestAccess(timeout: TimeInterval = 30, starter: @escaping Starter) throws -> Bool {
|
||||
final class Box: @unchecked Sendable {
|
||||
var granted: Bool = false
|
||||
var error: Error? = nil
|
||||
var done: Bool = false
|
||||
let lock = NSLock()
|
||||
func setOnce(granted: Bool, error: Error?) -> Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard !done else { return false }
|
||||
self.granted = granted
|
||||
self.error = error
|
||||
self.done = true
|
||||
return true
|
||||
}
|
||||
func snapshot() -> (done: Bool, granted: Bool, error: Error?) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return (done, granted, error)
|
||||
}
|
||||
}
|
||||
|
||||
let box = Box()
|
||||
let completion: @Sendable (Bool, Error?) -> Void = { granted, error in
|
||||
_ = box.setOnce(granted: granted, error: error)
|
||||
}
|
||||
|
||||
starter(completion)
|
||||
|
||||
let deadline = Date(timeIntervalSinceNow: timeout)
|
||||
while true {
|
||||
let snap = box.snapshot()
|
||||
if snap.done { break }
|
||||
if Date() >= deadline { break }
|
||||
let next = Date(timeIntervalSinceNow: 0.02)
|
||||
_ = RunLoop.current.run(mode: .default, before: next)
|
||||
}
|
||||
|
||||
let final = box.snapshot()
|
||||
if !final.done {
|
||||
throw RemindersProviderError.unavailable("reminders authorization timed out")
|
||||
}
|
||||
if let err = final.error {
|
||||
throw RemindersProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
return final.granted
|
||||
}
|
||||
}
|
||||
|
||||
struct RemindersAuthorizationProvider: RemindersAuthorizationProviding {
|
||||
var bridge: RemindersMainRunLoopBridge = RemindersMainRunLoopBridge()
|
||||
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus {
|
||||
let s = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch s {
|
||||
case .fullAccess, .authorized:
|
||||
return .authorized
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
case .writeOnly:
|
||||
return .writeOnly
|
||||
@unknown default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
func requestFullAccess() throws -> Bool {
|
||||
if #available(macOS 14.0, *) {
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToReminders { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for macOS 13: requestAccess(to: .reminder)
|
||||
return try bridge.requestAccess(timeout: 30) { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestAccess(to: .reminder) { granted, error in
|
||||
completion(granted, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// MARK: - Reminders data models
|
||||
struct ReminderListItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let source: String
|
||||
let type: String // local/caldav/exchange/etc
|
||||
}
|
||||
|
||||
struct ReminderItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let list_id: String
|
||||
let list_title: String
|
||||
let title: String
|
||||
let notes: String?
|
||||
let completed: Bool
|
||||
let due: String? // ISO8601 or nil
|
||||
let priority: Int // 0-9 (EKReminderPriority mapped)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case list_id
|
||||
case list_title
|
||||
case title
|
||||
case notes
|
||||
case completed
|
||||
case due
|
||||
case priority
|
||||
}
|
||||
}
|
||||
|
||||
struct ReminderCreateResult: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let list_id: String
|
||||
let list_title: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
enum RemindersProviderError: Error, Equatable, Sendable {
|
||||
case permissionRequired
|
||||
case permissionDenied
|
||||
case unavailable(String)
|
||||
case invalidRequest(String)
|
||||
|
||||
var isPermission: Bool {
|
||||
if case .permissionRequired = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider protocols
|
||||
protocol RemindersListsProviding: Sendable {
|
||||
func listReminderLists() throws -> [ReminderListItem]
|
||||
}
|
||||
|
||||
protocol RemindersListProviding: Sendable {
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem]
|
||||
}
|
||||
|
||||
protocol RemindersCreateProviding: Sendable {
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult
|
||||
}
|
||||
|
||||
protocol FullRemindersProviding: RemindersListsProviding, RemindersListProviding, RemindersCreateProviding {}
|
||||
|
||||
// MARK: - Production EventKit provider (scoped read non-prompt; create requires explicit list)
|
||||
struct EventKitRemindersProvider: RemindersListsProviding, RemindersListProviding, RemindersCreateProviding {
|
||||
|
||||
// Shared auth check – only checks status, never prompts
|
||||
private func requireAuthorizedForRead() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
return
|
||||
case .writeOnly:
|
||||
// writeOnly does not permit listing reminders per EventKit; treat as permission_required for list operations
|
||||
throw RemindersProviderError.permissionRequired
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func requireAuthorizedForCreate() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch status {
|
||||
case .fullAccess, .authorized, .writeOnly:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func allReminderCalendars(store: EKEventStore) -> [EKCalendar] {
|
||||
store.calendars(for: .reminder)
|
||||
}
|
||||
|
||||
private func resolveForList(store: EKEventStore, listId: String?, listTitle: String?) throws -> [EKCalendar] {
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
if let lid = listId, !lid.isEmpty {
|
||||
if let found = calendars.first(where: { $0.calendarIdentifier == lid }) {
|
||||
return [found]
|
||||
} else {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(lid)")
|
||||
}
|
||||
}
|
||||
if let title = listTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw RemindersProviderError.invalidRequest("Ambiguous reminders list title: \(title) matches \(matched.count) lists")
|
||||
}
|
||||
return matched
|
||||
}
|
||||
return calendars
|
||||
}
|
||||
|
||||
private func resolveForCreate(store: EKEventStore, listId: String?, listTitle: String?) throws -> EKCalendar {
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
if let lid = listId, !lid.isEmpty {
|
||||
guard let found = calendars.first(where: { $0.calendarIdentifier == lid }) else {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(lid)")
|
||||
}
|
||||
if !found.allowsContentModifications {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
if let title = listTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw RemindersProviderError.invalidRequest("Ambiguous reminders list title: \(title) matches \(matched.count) lists")
|
||||
}
|
||||
let found = matched[0]
|
||||
if !found.allowsContentModifications {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
throw RemindersProviderError.invalidRequest("Reminders list must be specified by id or exact unique title")
|
||||
}
|
||||
|
||||
// MARK: - lists
|
||||
func listReminderLists() throws -> [ReminderListItem] {
|
||||
try requireAuthorizedForRead()
|
||||
let store = EKEventStore()
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
return calendars.map { cal in
|
||||
let sourceTitle = cal.source.title
|
||||
let typeString: String
|
||||
switch cal.type {
|
||||
case .local: typeString = "local"
|
||||
case .calDAV: typeString = "caldav"
|
||||
case .exchange: typeString = "exchange"
|
||||
case .subscription: typeString = "subscription"
|
||||
case .birthday: typeString = "birthday"
|
||||
@unknown default: typeString = "unknown"
|
||||
}
|
||||
return ReminderListItem(id: cal.calendarIdentifier, title: cal.title, source: sourceTitle, type: typeString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - list reminders
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] {
|
||||
try requireAuthorizedForRead()
|
||||
let store = EKEventStore()
|
||||
let targetCalendars: [EKCalendar]
|
||||
do {
|
||||
targetCalendars = try resolveForList(store: store, listId: listId, listTitle: listTitle)
|
||||
} catch let e as RemindersProviderError {
|
||||
throw e
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Reminders lookup failed")
|
||||
}
|
||||
if targetCalendars.isEmpty { return [] }
|
||||
|
||||
let predicate = store.predicateForReminders(in: targetCalendars)
|
||||
var fetched: [EKReminder] = []
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var fetchError: Error? = nil
|
||||
store.fetchReminders(matching: predicate) { rems in
|
||||
fetched = rems ?? []
|
||||
sem.signal()
|
||||
}
|
||||
// fetchReminders is async on newer APIs? In EventKit even on macOS 13 fetchReminders matching is async via completion.
|
||||
// Wait bounded 10s
|
||||
let waitRes = sem.wait(timeout: .now() + 10)
|
||||
if waitRes == .timedOut {
|
||||
throw RemindersProviderError.unavailable("Reminders fetch timed out")
|
||||
}
|
||||
if let err = fetchError {
|
||||
throw RemindersProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
|
||||
var items: [ReminderItem] = []
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
|
||||
for rem in fetched {
|
||||
let isCompleted = rem.isCompleted
|
||||
if let filterCompleted = completed, filterCompleted != isCompleted { continue }
|
||||
guard let cal = rem.calendar else { continue }
|
||||
let dueStr: String?
|
||||
if let comps = rem.dueDateComponents, let d = Calendar.current.date(from: comps) {
|
||||
dueStr = iso.string(from: d)
|
||||
} else {
|
||||
dueStr = nil
|
||||
}
|
||||
let item = ReminderItem(
|
||||
id: rem.calendarItemIdentifier,
|
||||
list_id: cal.calendarIdentifier,
|
||||
list_title: cal.title,
|
||||
title: rem.title ?? "",
|
||||
notes: rem.notes,
|
||||
completed: isCompleted,
|
||||
due: dueStr,
|
||||
priority: rem.priority
|
||||
)
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
// Deterministic sort: due, title, id
|
||||
items.sort {
|
||||
let due0 = $0.due ?? ""
|
||||
let due1 = $1.due ?? ""
|
||||
if due0 != due1 { return due0 < due1 }
|
||||
if $0.title != $1.title { return $0.title < $1.title }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
|
||||
if items.count > limit {
|
||||
return Array(items.prefix(limit))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - create
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
|
||||
try requireAuthorizedForCreate()
|
||||
guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw RemindersProviderError.invalidRequest("Missing required argument: title")
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let destination: EKCalendar
|
||||
do {
|
||||
destination = try resolveForCreate(store: store, listId: listId, listTitle: listTitle)
|
||||
} catch let e as RemindersProviderError {
|
||||
throw e
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Reminders list lookup failed")
|
||||
}
|
||||
|
||||
let rem = EKReminder(eventStore: store)
|
||||
rem.title = title
|
||||
rem.calendar = destination
|
||||
rem.notes = notes
|
||||
if let p = priority {
|
||||
rem.priority = p
|
||||
}
|
||||
if let dueDate = due {
|
||||
let comps = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: dueDate)
|
||||
rem.dueDateComponents = comps
|
||||
}
|
||||
|
||||
do {
|
||||
try store.save(rem, commit: true)
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Failed to save reminder: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
return ReminderCreateResult(
|
||||
id: rem.calendarItemIdentifier,
|
||||
list_id: destination.calendarIdentifier,
|
||||
list_title: destination.title,
|
||||
title: rem.title ?? title
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
// MARK: - Pure, testable validation types
|
||||
|
||||
struct LStatInfo {
|
||||
var uid: uid_t
|
||||
var mode: mode_t // full st_mode
|
||||
var isSymlink: Bool
|
||||
var isDir: Bool
|
||||
var exists: Bool
|
||||
}
|
||||
|
||||
/// Authoritative result of an lstat call: present, absent (ENOENT), or failed with errno.
|
||||
enum LStatResult {
|
||||
case present(LStatInfo)
|
||||
case absent
|
||||
case failed(errnoCode: Int32)
|
||||
}
|
||||
|
||||
/// New authoritative provider that never swallows errors.
|
||||
typealias LStatResultProvider = (String) -> LStatResult
|
||||
|
||||
/// Legacy optional provider kept for existing pure tests: nil == absent (ENOENT).
|
||||
/// New code should use LStatResultProvider.
|
||||
typealias LStatProvider = (String) -> LStatInfo?
|
||||
|
||||
// MARK: - Trust boundary documentation
|
||||
|
||||
/*
|
||||
Trust boundary for socket parent-chain validation – tiered model:
|
||||
|
||||
Tiers (evaluated per existing component, fail-closed on lstat errors):
|
||||
|
||||
1) Platform-trusted ancestors – explicit allowlist ONLY:
|
||||
"/", "/Users", "/private", "/var", "/tmp",
|
||||
"/private/tmp", "/var/tmp", "/private/var", "/private/var/tmp"
|
||||
Hard-coded in `platformTrustedRootPaths`.
|
||||
- lstat non-symlink dir (except /var and /tmp which are known macOS symlinks and allowed as symlink)
|
||||
- uid 0
|
||||
- non-tmp platform paths ("/", "/Users", "/private", "/private/var"): no group/other write (mode & 022 == 0), 0755 allowed
|
||||
- tmp platform paths ("/private/tmp", "/var/tmp", "/private/var/tmp", plus "/tmp","/var" as dirs): uid 0 only, may be 1777 sticky
|
||||
|
||||
2) User-owned intermediate ancestors (e.g. $HOME = /Users/<user>, ~/Library, ~/Library/Application Support, ...):
|
||||
- not a symlink
|
||||
- a directory
|
||||
- owned by current uid (getuid())
|
||||
- no group/other *write* (mode & 022 == 0)
|
||||
-> allows 0700, 0750, 0755 (standard macOS home is 0750 = rwxr-x---) but rejects 0770/0777 or any writable bit
|
||||
Reason: home 0750 is default on some installs; privacy is still enforced by tier 3.
|
||||
|
||||
3) Dedicated runtime socket parent – the immediate parent dir of the socket (e.g. .../reyna-cli/privacy):
|
||||
- not a symlink
|
||||
- a directory
|
||||
- owned by current uid
|
||||
- strictly no group/other bits at all (mode & 077 == 0) => 0700 family only, rejects 0750/0755
|
||||
+ socket file itself must be 0600 (enforced in SocketServer bind/chmod)
|
||||
|
||||
- Never trust arbitrary root-owned intermediate paths outside explicit allowlist.
|
||||
|
||||
This is the fix for: home 0750 was incorrectly rejected (validator required 0700 for all user components),
|
||||
causing "Refusing socket path: parent component /Users/<user> has group/other permissions: 750".
|
||||
Now tier 2 allows 0750 for home/intermediates, tier 3 keeps 0700 for the privacy dir.
|
||||
*/
|
||||
|
||||
let platformTrustedRootPaths: Set<String> = [
|
||||
"/",
|
||||
"/Users",
|
||||
"/private",
|
||||
"/var",
|
||||
"/tmp",
|
||||
"/private/tmp",
|
||||
"/var/tmp",
|
||||
"/private/var",
|
||||
"/private/var/tmp"
|
||||
]
|
||||
|
||||
// Symlink-allowed platform paths – macOS ships /tmp -> private/tmp and /var -> private/var
|
||||
let platformSymlinkAllowedPaths: Set<String> = [
|
||||
"/tmp",
|
||||
"/var"
|
||||
]
|
||||
|
||||
func isRootTrustedPath(_ p: String) -> Bool {
|
||||
return platformTrustedRootPaths.contains(p)
|
||||
}
|
||||
|
||||
func isSymlinkAllowedPlatformPath(_ p: String) -> Bool {
|
||||
return platformSymlinkAllowedPaths.contains(p)
|
||||
}
|
||||
|
||||
func rejectIfDotComponentsPure(in socketPath: String) throws {
|
||||
let url = URL(fileURLWithPath: socketPath)
|
||||
for comp in url.pathComponents {
|
||||
if comp == "." || comp == ".." {
|
||||
throw NSError(domain: "SocketServer", code: 20, userInfo: [NSLocalizedDescriptionKey: "Socket path must not contain '.' or '..' components: \(socketPath)"])
|
||||
}
|
||||
}
|
||||
let standardized = url.standardized.path
|
||||
if standardized != socketPath {
|
||||
let stdComps = URL(fileURLWithPath: standardized).pathComponents
|
||||
let origComps = url.pathComponents
|
||||
if stdComps != origComps {
|
||||
for c in stdComps {
|
||||
if c == "." || c == ".." {
|
||||
throw NSError(domain: "SocketServer", code: 21, userInfo: [NSLocalizedDescriptionKey: "Socket path contains invalid components after standardization"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Core validation using authoritative provider
|
||||
|
||||
private func lstatInfo(from st: stat) -> LStatInfo {
|
||||
let isSymlink = (st.st_mode & S_IFMT) == S_IFLNK
|
||||
let isDir = (st.st_mode & S_IFMT) == S_IFDIR
|
||||
// For symlink itself, isDir should be false so caller can distinguish
|
||||
return LStatInfo(uid: st.st_uid, mode: st.st_mode, isSymlink: isSymlink, isDir: isSymlink ? false : isDir, exists: true)
|
||||
}
|
||||
|
||||
/// Single-component authoritative validator reused by both chain validation and ensureParentDirectories.
|
||||
/// This is the sole place that encodes trusted-root vs user-owned policy.
|
||||
/// Tiers:
|
||||
/// 1) platform trusted (explicit allowlist) – uid 0, dir, no g/o write except tmp exemptions, symlink allowed only for /tmp / /var
|
||||
/// 2) user-owned intermediate ancestors – uid current, not symlink, dir, mode & 022 == 0 (allows 0700/0750/0755, rejects writable)
|
||||
/// 3) dedicated runtime parent (immediate socket parent) – uid current, not symlink, dir, mode & 077 == 0 (requires 0700 family)
|
||||
///
|
||||
/// - For symlink: only /tmp and /var may be symlink (macOS aliases), else reject.
|
||||
/// - For non-dir file: always reject (including /tmp /var as dir target).
|
||||
func validateSingleLStatInfoOrThrow(path: String, info: LStatInfo, currentUID: uid_t, isDedicatedRuntimeParent: Bool = false) throws {
|
||||
if info.isSymlink {
|
||||
if isSymlinkAllowedPlatformPath(path) {
|
||||
return
|
||||
}
|
||||
throw NSError(domain: "SocketServer", code: 23, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) is a symlink"])
|
||||
}
|
||||
if !info.isDir {
|
||||
// A regular file (or other non-dir) at any parent component, including /tmp /var, must reject
|
||||
throw NSError(domain: "SocketServer", code: 10, userInfo: [NSLocalizedDescriptionKey: "Parent path exists but is not a directory: \(path)"])
|
||||
}
|
||||
if path == "/" || isRootTrustedPath(path) {
|
||||
if path == "/tmp" || path == "/var" {
|
||||
if info.uid != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if path == "/private/tmp" || path == "/var/tmp" || path == "/private/var/tmp" {
|
||||
if info.uid != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if info.uid != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
if (info.mode & 0o022) != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(info.mode & 0o777, radix: 8))"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if info.uid != currentUID {
|
||||
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
|
||||
}
|
||||
let perms = info.mode & 0o777
|
||||
if isDedicatedRuntimeParent {
|
||||
// Tier 3: dedicated runtime must be exactly 0700 family – no group/other bits
|
||||
if (perms & 0o077) != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(perms, radix: 8))"])
|
||||
}
|
||||
} else {
|
||||
// Tier 2: intermediate user-owned – no group/other write (allows 0750/0755, rejects 0770/0777)
|
||||
if (perms & 0o022) != 0 {
|
||||
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(perms, radix: 8))"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateParentChainPureResultProvider(socketPath: String, currentUID: uid_t, provider: LStatResultProvider) throws {
|
||||
try rejectIfDotComponentsPure(in: socketPath)
|
||||
let parentURL = URL(fileURLWithPath: socketPath).deletingLastPathComponent()
|
||||
let parentPath = parentURL.path
|
||||
if parentPath.isEmpty || parentPath == "/" { return }
|
||||
|
||||
let comps = parentURL.pathComponents // starts with "/"
|
||||
var cur = ""
|
||||
for comp in comps {
|
||||
if comp == "/" {
|
||||
cur = "/"
|
||||
continue
|
||||
}
|
||||
if cur == "/" {
|
||||
cur = "/" + comp
|
||||
} else if cur.isEmpty {
|
||||
cur = comp
|
||||
} else {
|
||||
cur = cur + "/" + comp
|
||||
}
|
||||
|
||||
let result = provider(cur)
|
||||
switch result {
|
||||
case .absent:
|
||||
continue
|
||||
case .failed(let errnoCode):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for parent component \(cur): \(String(cString: strerror(errnoCode)))"])
|
||||
case .present(let info):
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: currentUID, isDedicatedRuntimeParent: isDedicated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure parent-chain validator with injectable lstat and uid (legacy nil==ENOENT shim).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - socketPath: absolute socket path
|
||||
/// - currentUID: uid of current process
|
||||
/// - provider: returns LStatInfo? (nil if ENOENT, else info). Must use lstat, not stat.
|
||||
/// - Throws: on policy violation
|
||||
func validateParentChainPure(socketPath: String, currentUID: uid_t, provider: LStatProvider) throws {
|
||||
// Adapt legacy optional provider into authoritative result provider
|
||||
let adapted: LStatResultProvider = { path in
|
||||
if let info = provider(path) {
|
||||
return .present(info)
|
||||
} else {
|
||||
return .absent
|
||||
}
|
||||
}
|
||||
try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: currentUID, provider: adapted)
|
||||
}
|
||||
|
||||
// MARK: - Live lstat adapter
|
||||
|
||||
/// Authoritative live lstat that never swallows non-ENOENT errors.
|
||||
func liveLStatResultProvider(path: String) -> LStatResult {
|
||||
var st = stat()
|
||||
if lstat(path, &st) != 0 {
|
||||
if errno == ENOENT { return .absent }
|
||||
return .failed(errnoCode: errno)
|
||||
}
|
||||
return .present(lstatInfo(from: st))
|
||||
}
|
||||
|
||||
/// Legacy optional lstat provider. Now fail-closed: returns nil ONLY for ENOENT, and for
|
||||
/// other errors returns a present but invalid sentinel that will cause validation to reject
|
||||
/// (never treated as missing). Prefer liveLStatResultProvider.
|
||||
func liveLStatProvider(path: String) -> LStatInfo? {
|
||||
switch liveLStatResultProvider(path: path) {
|
||||
case .absent:
|
||||
return nil
|
||||
case .present(let info):
|
||||
return info
|
||||
case .failed:
|
||||
// Fail-closed sentinel: not a directory, wrong uid, triggers rejection if misused directly
|
||||
// We return an info that will be rejected as non-directory
|
||||
return LStatInfo(uid: uid_t.max, mode: 0, isSymlink: false, isDir: false, exists: true)
|
||||
}
|
||||
}
|
||||
|
||||
func validateExistingParentChainLive(for socketPath: String) throws {
|
||||
let currentUID = getuid()
|
||||
// Single authoritative scan using liveLStatResultProvider; no pre-scan duplicate, no nil-swallow
|
||||
try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: currentUID, provider: { liveLStatResultProvider(path: $0) })
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import CSignalSupport
|
||||
|
||||
// Pure decision extracted for unit testing.
|
||||
func isPeerAuthorized(peerUID: uid_t, currentUID: uid_t) -> Bool {
|
||||
return peerUID == currentUID
|
||||
}
|
||||
|
||||
// MARK: - Path validation
|
||||
// Trust boundary: see SocketPathValidation.swift for full documentation.
|
||||
// Tiered model:
|
||||
// 1) platform allowlist root-owned (uid 0, no g/o write except tmp)
|
||||
// 2) user-owned intermediates: uid current, no g/o *write* (mode & 022 == 0) -> allows 0700/0750/0755, rejects 0770/0777
|
||||
// 3) dedicated runtime (immediate socket parent): uid current, mode & 077 == 0 -> requires 0700 family only.
|
||||
// Socket itself 0600.
|
||||
|
||||
// MARK: - Reused single-component validator (authoritative)
|
||||
// NOTE: this is the ONLY place allowed to decide if an existing component is safe.
|
||||
// It must stay in sync with validateParentChainPureResultProvider logic.
|
||||
func validateExistingComponentLiveOrThrow(path: String, info: LStatInfo, currentUID: uid_t, isDedicatedRuntimeParent: Bool = false) throws {
|
||||
// Centralized call to shared validation in SocketPathValidation
|
||||
try validateSingleLStatInfoOrThrow(path: path, info: info, currentUID: currentUID, isDedicatedRuntimeParent: isDedicatedRuntimeParent)
|
||||
}
|
||||
|
||||
func ensureParentDirectories(for socketPath: String) throws {
|
||||
// Authoritative validation reused; fail-closed on lstat errors
|
||||
try rejectIfDotComponentsPure(in: socketPath)
|
||||
try validateExistingParentChainLive(for: socketPath)
|
||||
|
||||
let fm = FileManager.default
|
||||
let url = URL(fileURLWithPath: socketPath)
|
||||
let parent = url.deletingLastPathComponent()
|
||||
let parentPath = parent.path
|
||||
if parentPath.isEmpty { return }
|
||||
|
||||
let comps = parent.pathComponents
|
||||
var cur = ""
|
||||
for comp in comps {
|
||||
if comp == "/" {
|
||||
cur = "/"
|
||||
continue
|
||||
}
|
||||
if cur == "/" {
|
||||
cur = "/" + comp
|
||||
} else if cur.isEmpty {
|
||||
cur = comp
|
||||
} else {
|
||||
cur = cur + "/" + comp
|
||||
}
|
||||
|
||||
switch liveLStatResultProvider(path: cur) {
|
||||
case .absent:
|
||||
// Create missing component with 0700 – privacy preserving. Even intermediates now get 0700.
|
||||
do {
|
||||
try fm.createDirectory(atPath: cur, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
|
||||
chmod(cur, 0o700)
|
||||
} catch {
|
||||
// mkdir race: re-lstat and revalidate rather than assuming missing
|
||||
switch liveLStatResultProvider(path: cur) {
|
||||
case .absent:
|
||||
throw error
|
||||
case .failed(let ec):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(cur): \(String(cString: strerror(ec)))"])
|
||||
case .present(let info):
|
||||
do {
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: getuid(), isDedicatedRuntimeParent: isDedicated)
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
case .failed(let ec):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(cur): \(String(cString: strerror(ec)))"])
|
||||
case .present(let info):
|
||||
// Reuse authoritative single-component validator (no duplicated policy)
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: getuid(), isDedicatedRuntimeParent: isDedicated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func safeUnlinkIfStaleSocket(at path: String) throws {
|
||||
var st = stat()
|
||||
let r = lstat(path, &st)
|
||||
if r != 0 {
|
||||
if errno == ENOENT { return }
|
||||
throw NSError(domain: "SocketServer", code: 11, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(path): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
let isSock = (st.st_mode & S_IFMT) == S_IFSOCK
|
||||
if !isSock {
|
||||
throw NSError(domain: "SocketServer", code: 12, userInfo: [NSLocalizedDescriptionKey: "Refusing to unlink: \(path) exists and is not a socket"])
|
||||
}
|
||||
if st.st_uid != getuid() {
|
||||
throw NSError(domain: "SocketServer", code: 13, userInfo: [NSLocalizedDescriptionKey: "Refusing to unlink: socket at \(path) not owned by current uid"])
|
||||
}
|
||||
if unlink(path) != 0 && errno != ENOENT {
|
||||
throw NSError(domain: "SocketServer", code: 14, userInfo: [NSLocalizedDescriptionKey: "Failed to unlink stale socket \(path): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
}
|
||||
|
||||
private let kMaxRequestBytes = 64 * 1024
|
||||
private let kClientRecvTimeoutSec = 5
|
||||
|
||||
private func makeErrorResponse(id: String, code: String, message: String) -> Data? {
|
||||
let err = ErrorPayload(code: code, message: message)
|
||||
let resp = Response(id: id, ok: false, result: nil, error: err)
|
||||
guard let json = try? JSONEncoder().encode(resp),
|
||||
let str = String(data: json, encoding: .utf8) else { return nil }
|
||||
return (str + "\n").data(using: .utf8)
|
||||
}
|
||||
|
||||
private func processRequestData(_ data: Data) -> Data? {
|
||||
if data.isEmpty { return nil }
|
||||
if let s = String(data: data, encoding: .utf8),
|
||||
s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return nil
|
||||
}
|
||||
if let req = try? JSONDecoder().decode(Request.self, from: data) {
|
||||
let resp = dispatch(request: req)
|
||||
guard let json = try? JSONEncoder().encode(resp),
|
||||
let str = String(data: json, encoding: .utf8) else { return nil }
|
||||
return (str + "\n").data(using: .utf8)
|
||||
} else {
|
||||
let recovered = extractRecoverableId(from: data) ?? ""
|
||||
return makeErrorResponse(id: recovered, code: "invalid_request", message: "Invalid request JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func runSocketServer(socketPath: String) -> Never {
|
||||
if !socketPath.hasPrefix("/") {
|
||||
fputs("error: --socket path must be absolute: \(socketPath)\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
if socketPath.utf8.count >= 104 {
|
||||
fputs("error: --socket path too long\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
|
||||
do {
|
||||
try ensureParentDirectories(for: socketPath)
|
||||
try safeUnlinkIfStaleSocket(at: socketPath)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
Darwin.exit(3)
|
||||
}
|
||||
|
||||
// Store for signal cleanup in C
|
||||
socketPath.withCString { cStr in
|
||||
reyna_store_socket_path(cStr)
|
||||
}
|
||||
reyna_install_signal_handlers()
|
||||
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
if fd < 0 {
|
||||
fputs("error: socket() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
Darwin.exit(4)
|
||||
}
|
||||
_ = fcntl(fd, F_SETFD, FD_CLOEXEC)
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = socketPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
|
||||
dst.withMemoryRebound(to: CChar.self, capacity: 104) { p in
|
||||
strncpy(p, cStr, 103)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let oldMask = umask(0o077)
|
||||
let bindRes = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
|
||||
bind(fd, saddr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
umask(oldMask)
|
||||
|
||||
if bindRes != 0 {
|
||||
fputs("error: bind() \(socketPath): \(String(cString: strerror(errno)))\n", stderr)
|
||||
close(fd)
|
||||
Darwin.exit(5)
|
||||
}
|
||||
|
||||
if chmod(socketPath, 0o600) != 0 {
|
||||
fputs("warning: chmod 0600 failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
}
|
||||
|
||||
if listen(fd, 32) != 0 {
|
||||
fputs("error: listen() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
close(fd)
|
||||
reyna_cleanup_socket_sync()
|
||||
Darwin.exit(6)
|
||||
}
|
||||
|
||||
// Main loop: one client at a time, one request per connection
|
||||
while true {
|
||||
let cfd = accept(fd, nil, nil)
|
||||
if cfd < 0 {
|
||||
if errno == EINTR { continue }
|
||||
fputs("error: accept() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
break
|
||||
}
|
||||
|
||||
// --- Peer credential check (macOS getpeereid) ---
|
||||
var peerEuid: uid_t = 0
|
||||
var peerEgid: gid_t = 0
|
||||
if getpeereid(cfd, &peerEuid, &peerEgid) != 0 {
|
||||
// If we cannot obtain peer credentials, reject
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
if !isPeerAuthorized(peerUID: peerEuid, currentUID: getuid()) {
|
||||
if let d = makeErrorResponse(id: "", code: "unauthorized", message: "Peer UID not authorized") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set receive timeout to bound slow clients
|
||||
var tv = timeval()
|
||||
tv.tv_sec = kClientRecvTimeoutSec
|
||||
tv.tv_usec = 0
|
||||
setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
|
||||
var buf = Data()
|
||||
buf.reserveCapacity(8192)
|
||||
var tmp = [UInt8](repeating: 0, count: 4096)
|
||||
var exceeded = false
|
||||
var gotAny = false
|
||||
var timedOut = false
|
||||
|
||||
// Poll-based timeout additionally enforced
|
||||
while true {
|
||||
// Wait for data with timeout
|
||||
var pfd = pollfd(fd: cfd, events: Int16(POLLIN), revents: 0)
|
||||
let pollTimeoutMs: Int32 = Int32(kClientRecvTimeoutSec * 1000)
|
||||
let pr = poll(&pfd, 1, pollTimeoutMs)
|
||||
if pr < 0 {
|
||||
if errno == EINTR { continue }
|
||||
break
|
||||
}
|
||||
if pr == 0 {
|
||||
// timeout
|
||||
timedOut = true
|
||||
break
|
||||
}
|
||||
let n = recv(cfd, &tmp, tmp.count, 0)
|
||||
if n < 0 {
|
||||
if errno == EINTR { continue }
|
||||
if errno == EWOULDBLOCK || errno == EAGAIN {
|
||||
timedOut = true
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
if n == 0 { break }
|
||||
gotAny = true
|
||||
|
||||
// Oversized handling with newline-in-same-chunk fix
|
||||
if buf.count + n > kMaxRequestBytes {
|
||||
// Look for newline in the new chunk
|
||||
var newlineIdx: Int? = nil
|
||||
for i in 0..<n {
|
||||
if tmp[i] == 0x0A {
|
||||
newlineIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if let nl = newlineIdx {
|
||||
// First line length = buf.count + nl (excluding newline char itself)
|
||||
let firstLineLen = buf.count + nl
|
||||
if firstLineLen <= kMaxRequestBytes {
|
||||
// Accept up to newline and ignore rest
|
||||
buf.append(contentsOf: tmp[0..<nl])
|
||||
// Break to process – we have a complete line within limit
|
||||
break
|
||||
} else {
|
||||
exceeded = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// No newline in this chunk and would exceed -> oversized
|
||||
exceeded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
buf.append(contentsOf: tmp[0..<n])
|
||||
if buf.contains(0x0A) { break }
|
||||
}
|
||||
|
||||
if timedOut {
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if !gotAny && !exceeded {
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if exceeded {
|
||||
if let d = makeErrorResponse(id: "", code: "payload_too_large", message: "Request exceeds 64KiB limit") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
let lineData: Data
|
||||
if let idx = buf.firstIndex(of: 0x0A) {
|
||||
lineData = buf.prefix(upTo: idx)
|
||||
} else {
|
||||
lineData = buf
|
||||
}
|
||||
|
||||
if lineData.count > kMaxRequestBytes {
|
||||
if let d = makeErrorResponse(id: "", code: "payload_too_large", message: "Request exceeds 64KiB limit") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if let resp = processRequestData(lineData) {
|
||||
_ = resp.withUnsafeBytes { p in
|
||||
var sent = 0
|
||||
while sent < resp.count {
|
||||
let n = send(cfd, p.baseAddress!.advanced(by: sent), resp.count - sent, 0)
|
||||
if n <= 0 { break }
|
||||
sent += n
|
||||
}
|
||||
}
|
||||
}
|
||||
close(cfd)
|
||||
}
|
||||
|
||||
close(fd)
|
||||
reyna_cleanup_socket_sync()
|
||||
Darwin.exit(0)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - System info data models (read-only, no TCC)
|
||||
|
||||
struct SystemInfoItem: Codable, Equatable, Sendable {
|
||||
let macos_version: String
|
||||
let build: String?
|
||||
let uname: String?
|
||||
let hw_model: String?
|
||||
let cpu_brand: String?
|
||||
let is_macos_26_plus: Bool?
|
||||
let speech_analyzer_expected: String?
|
||||
}
|
||||
|
||||
struct SpeechApiStatusItem: Codable, Equatable, Sendable {
|
||||
let system: SystemInfoItem
|
||||
let swift_availability: [String: String]? // minimal stub
|
||||
let conclusion: String
|
||||
}
|
||||
|
||||
enum SystemProviderError: Error, Equatable, Sendable {
|
||||
case unavailable(String)
|
||||
}
|
||||
|
||||
protocol SystemInfoProviding: Sendable {
|
||||
func getSystemInfo() throws -> SystemInfoItem
|
||||
func getSpeechApiStatus() throws -> SpeechApiStatusItem
|
||||
}
|
||||
|
||||
// Production provider - reads sw_vers / uname / sysctl, no permission needed
|
||||
struct ProductionSystemInfoProvider: SystemInfoProviding {
|
||||
func getSystemInfo() throws -> SystemInfoItem {
|
||||
let ver = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let verString = "\(ver.majorVersion).\(ver.minorVersion).\(ver.patchVersion)"
|
||||
// Best-effort hw model / cpu / uname without spawning processes in Swift? Use sysctl/mib.
|
||||
var hwModel: String? = nil
|
||||
var cpuBrand: String? = nil
|
||||
var unameStr: String? = nil
|
||||
// Use ProcessInfo hostName as fallback for minimal
|
||||
// For hw.model, use sysctlbyname where possible - but keep simple fallback to avoid C interop complexity
|
||||
// We'll try reading via sysctl nametable via Foundation
|
||||
#if os(macOS)
|
||||
hwModel = sysctlString("hw.model")
|
||||
cpuBrand = sysctlString("machdep.cpu.brand_string")
|
||||
#endif
|
||||
let m = ver.majorVersion
|
||||
let is26 = m >= 26
|
||||
let expected = is26 ? "likely available (macOS 26+)" : "not available - requires macOS 26+"
|
||||
return SystemInfoItem(
|
||||
macos_version: verString,
|
||||
build: nil,
|
||||
uname: unameStr,
|
||||
hw_model: hwModel,
|
||||
cpu_brand: cpuBrand,
|
||||
is_macos_26_plus: is26,
|
||||
speech_analyzer_expected: expected
|
||||
)
|
||||
}
|
||||
|
||||
func getSpeechApiStatus() throws -> SpeechApiStatusItem {
|
||||
let info = try getSystemInfo()
|
||||
let major = ProcessInfo.processInfo.operatingSystemVersion.majorVersion
|
||||
let conclusion: String
|
||||
if major >= 26 {
|
||||
conclusion = "macOS 26+ detected — SpeechAnalyzer/SpeechTranscriber should be available per Apple docs."
|
||||
} else {
|
||||
conclusion = "macOS \(info.macos_version) detected — SpeechAnalyzer requires macOS 26+."
|
||||
}
|
||||
return SpeechApiStatusItem(system: info, swift_availability: nil, conclusion: conclusion)
|
||||
}
|
||||
|
||||
private func sysctlString(_ name: String) -> String? {
|
||||
var size = 0
|
||||
let rc1 = sysctlbyname(name, nil, &size, nil, 0)
|
||||
if rc1 != 0 { return nil }
|
||||
var buffer = [CChar](repeating: 0, count: size)
|
||||
let rc2 = sysctlbyname(name, &buffer, &size, nil, 0)
|
||||
if rc2 != 0 { return nil }
|
||||
return String(cString: buffer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
|
||||
// Tests for calendar.request_full_access – TDD, fake providers only
|
||||
|
||||
final class CalendarAuthorizationTests: XCTestCase {
|
||||
|
||||
// Fake auth providers
|
||||
struct AlreadyAuthorizedProvider: CalendarAuthorizationProviding {
|
||||
var requested = false
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .authorized }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
XCTFail("requestFullAccess must not be called when already authorized")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct NotDeterminedGrantedProvider: CalendarAuthorizationProviding {
|
||||
var statusCall = 0
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool { true }
|
||||
}
|
||||
|
||||
struct NotDeterminedDeniedProvider: CalendarAuthorizationProviding {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct DeniedProvider: CalendarAuthorizationProviding {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .denied }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct TimeoutProvider: CalendarAuthorizationProviding {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
throw CalendarProviderError.unavailable("calendar authorization timed out")
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorProvider: CalendarAuthorizationProviding {
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
throw CalendarProviderError.unavailable("disk error")
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyListProvider: CalendarListProviding {
|
||||
func listCalendars() throws -> [CalendarListItem] { [] }
|
||||
}
|
||||
|
||||
// already-full permission returns state authorized without asking
|
||||
func testAlreadyAuthorizedReturnsAuthorizedWithoutRequesting() {
|
||||
let auth = AlreadyAuthorizedProvider()
|
||||
let req = Request(id: "a1", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertEqual(resp.result?.operation, "calendar.request_full_access")
|
||||
XCTAssertEqual(resp.id, "a1")
|
||||
}
|
||||
|
||||
// notDetermined reaches request path
|
||||
func testNotDeterminedReachesRequestPath() {
|
||||
final class TrackingProvider: CalendarAuthorizationProviding, @unchecked Sendable {
|
||||
var didRequest = false
|
||||
var status: CalendarAuthorizationStatus = .notDetermined
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
didRequest = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
let tracking = TrackingProvider()
|
||||
let req = Request(id: "a2", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: tracking)
|
||||
XCTAssertTrue(tracking.didRequest, "requestFullAccess must be called when notDetermined")
|
||||
XCTAssertTrue(resp.ok)
|
||||
}
|
||||
|
||||
// granted response returns {status:"authorized"}
|
||||
func testGrantedReturnsAuthorizedResult() {
|
||||
let auth = NotDeterminedGrantedProvider()
|
||||
let req = Request(id: "a3", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION)
|
||||
XCTAssertNil(resp.error)
|
||||
XCTAssertNil(resp.result?.calendars, "must not output calendar content")
|
||||
}
|
||||
|
||||
// denied returns structured permission_denied
|
||||
func testDeniedReturnsPermissionDenied() {
|
||||
let auth = NotDeterminedDeniedProvider()
|
||||
let req = Request(id: "a4", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||||
XCTAssertNotNil(resp.error?.message)
|
||||
XCTAssertNil(resp.result)
|
||||
}
|
||||
|
||||
// denied when already denied also permission_denied
|
||||
func testAlreadyDeniedPathAlsoDenies() {
|
||||
let auth = DeniedProvider()
|
||||
let req = Request(id: "a5", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||||
}
|
||||
|
||||
// async timeout/error returns calendar_unavailable
|
||||
func testTimeoutReturnsCalendarUnavailable() {
|
||||
let auth = TimeoutProvider()
|
||||
let req = Request(id: "a6", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
|
||||
XCTAssertTrue(resp.error?.message.lowercased().contains("timed out") ?? false)
|
||||
}
|
||||
|
||||
func testErrorReturnsCalendarUnavailable() {
|
||||
let auth = ErrorProvider()
|
||||
let req = Request(id: "a7", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
|
||||
}
|
||||
|
||||
// event/calendar list cannot call request method (verify list path never requests)
|
||||
func testCalendarListDoesNotCallAuthRequest() {
|
||||
final class SpyListProvider: CalendarListProviding, @unchecked Sendable {
|
||||
var called = false
|
||||
func listCalendars() throws -> [CalendarListItem] {
|
||||
called = true
|
||||
return []
|
||||
}
|
||||
}
|
||||
final class SpyAuthProvider: CalendarAuthorizationProviding, @unchecked Sendable {
|
||||
var didCallStatus = false
|
||||
var didCallRequest = false
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus {
|
||||
didCallStatus = true
|
||||
return .authorized
|
||||
}
|
||||
func requestFullAccess() throws -> Bool {
|
||||
didCallRequest = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
let list = SpyListProvider()
|
||||
let auth = SpyAuthProvider()
|
||||
let req = Request(id: "list-1", operation: "calendar.list", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: list, authProvider: auth)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertFalse(auth.didCallRequest, "calendar.list must never call requestFullAccess")
|
||||
XCTAssertFalse(auth.didCallStatus, "calendar.list must not touch auth provider")
|
||||
XCTAssertTrue(list.called)
|
||||
}
|
||||
|
||||
// MARK: - Shared holder to satisfy Swift 6 Sendable checks
|
||||
final class TestBox<T>: @unchecked Sendable {
|
||||
var value: T
|
||||
init(_ v: T) { value = v }
|
||||
}
|
||||
|
||||
// MARK: - EventKitMainRunLoopBridge – deterministic pump tests (no real EventKit/TCC)
|
||||
|
||||
func testBridgePumpsMainRunLoopAndDeliversMainQueueCallback() throws {
|
||||
let exp = expectation(description: "bridge completes")
|
||||
let grantedBox = TestBox(false)
|
||||
let errorBox = TestBox<Error?>(nil)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let bridge = EventKitMainRunLoopBridge()
|
||||
do {
|
||||
// Deterministic seam: schedule callback onto next main run loop turn via Timer,
|
||||
// simulating EventKit delivering completion on main run loop.
|
||||
grantedBox.value = try bridge.requestAccess(timeout: 2) { completion in
|
||||
// Timer on main run loop – only fires when run loop is pumped
|
||||
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
|
||||
completion(true, nil)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorBox.value = error
|
||||
}
|
||||
exp.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [exp], timeout: 5)
|
||||
XCTAssertNil(errorBox.value, "bridge must not timeout when it pumps main run loop; got \(String(describing: errorBox.value))")
|
||||
XCTAssertTrue(grantedBox.value, "granted should be true after main-queue callback is pumped")
|
||||
}
|
||||
|
||||
func testSemaphoreDeadlocksButBridgeDoesNot() throws {
|
||||
let semExp = expectation(description: "old impl would timeout")
|
||||
let completedBox = TestBox(false)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
|
||||
completedBox.value = true
|
||||
sem.signal()
|
||||
}
|
||||
let res = sem.wait(timeout: .now() + 0.2)
|
||||
XCTAssertEqual(res, .timedOut, "Blocking semaphore on main thread must deadlock main-run-loop callback – proving old bug")
|
||||
XCTAssertFalse(completedBox.value, "Callback must not have run while semaphore blocked main loop")
|
||||
semExp.fulfill()
|
||||
}
|
||||
wait(for: [semExp], timeout: 2)
|
||||
}
|
||||
|
||||
func testBridgeHandlesCompletionExactlyOnce() throws {
|
||||
let exp = expectation(description: "exactly once")
|
||||
let resultBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = EventKitMainRunLoopBridge()
|
||||
do {
|
||||
resultBox.value = try bridge.requestAccess(timeout: 1) { completion in
|
||||
completion(true, nil)
|
||||
completion(false, NSError(domain: "should-be-ignored", code: 1))
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(resultBox.value, "First completion should win")
|
||||
}
|
||||
|
||||
func testBridgeThreadSafetyForConcurrentCompletion() throws {
|
||||
let exp = expectation(description: "thread-safe")
|
||||
let grantedBox = TestBox(false)
|
||||
let doneBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = EventKitMainRunLoopBridge()
|
||||
do {
|
||||
grantedBox.value = try bridge.requestAccess(timeout: 1) { completion in
|
||||
DispatchQueue.global().async { completion(true, nil) }
|
||||
DispatchQueue.global().async { completion(false, nil) }
|
||||
}
|
||||
doneBox.value = true
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(doneBox.value, "bridge must complete even with concurrent completions")
|
||||
}
|
||||
|
||||
func testBridgePropagatesError() throws {
|
||||
let exp = expectation(description: "error propagation")
|
||||
let caughtBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = EventKitMainRunLoopBridge()
|
||||
do {
|
||||
_ = try bridge.requestAccess(timeout: 1) { completion in
|
||||
completion(false, NSError(domain: "test", code: 2, userInfo: [NSLocalizedDescriptionKey: "fake EK error"]))
|
||||
}
|
||||
} catch let err as CalendarProviderError {
|
||||
if case .unavailable(let msg) = err {
|
||||
caughtBox.value = msg.contains("fake EK error")
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(caughtBox.value, "Error from EK completion must be wrapped as calendar_unavailable")
|
||||
}
|
||||
|
||||
func testBridgeTimeoutReturnsCorrectError() throws {
|
||||
let exp = expectation(description: "timeout")
|
||||
let codeBox = TestBox("")
|
||||
DispatchQueue.main.async {
|
||||
let bridge = EventKitMainRunLoopBridge()
|
||||
do {
|
||||
_ = try bridge.requestAccess(timeout: 0.15) { _ in }
|
||||
XCTFail("Should have thrown")
|
||||
} catch let err as CalendarProviderError {
|
||||
if case .unavailable(let msg) = err {
|
||||
codeBox.value = msg
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(codeBox.value.lowercased().contains("timed out"), "Timeout must produce 'calendar authorization timed out' message, got \(codeBox.value)")
|
||||
}
|
||||
|
||||
// Production code location check: only CalendarAuthorizationProvider.swift calls requestFullAccessToEvents
|
||||
func testOnlyOneFileCallsRequestFullAccessToEvents() throws {
|
||||
let fm = FileManager.default
|
||||
// Walk up to find repo root containing native/ReynaCLIHost/Sources
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var dirs: [URL] = []
|
||||
for _ in 0..<10 {
|
||||
let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost")
|
||||
if fm.fileExists(atPath: cand.path) {
|
||||
dirs.append(cand)
|
||||
}
|
||||
let candCore = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: candCore.path) {
|
||||
dirs.append(candCore)
|
||||
}
|
||||
let cand2 = cur.appendingPathComponent("Sources/ReynaCLIHost")
|
||||
if fm.fileExists(atPath: cand2.path) {
|
||||
dirs.append(cand2)
|
||||
}
|
||||
let candCore2 = cur.appendingPathComponent("Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: candCore2.path) {
|
||||
dirs.append(candCore2)
|
||||
}
|
||||
if !dirs.isEmpty { break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard !dirs.isEmpty else {
|
||||
XCTFail("Could not locate Sources/ReynaCLIHost dir")
|
||||
return
|
||||
}
|
||||
var hits: [String] = []
|
||||
for srcDir in dirs {
|
||||
let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? []
|
||||
for file in files where file.pathExtension == "swift" {
|
||||
guard let content = try? String(contentsOf: file) else { continue }
|
||||
if content.contains("requestFullAccessToEvents") {
|
||||
hits.append(file.lastPathComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dedupe + sort for stable assertion
|
||||
let uniqueSorted = Array(Set(hits)).sorted()
|
||||
XCTAssertEqual(uniqueSorted, ["CalendarAuthorizationProvider.swift"], "requestFullAccessToEvents must only appear in CalendarAuthorizationProvider.swift, found in \(uniqueSorted)")
|
||||
}
|
||||
|
||||
func testNoOutputCalendarContentOnAuthOperations() {
|
||||
// Both authorized and denied paths must not include calendars
|
||||
let authOk = NotDeterminedGrantedProvider()
|
||||
let reqOk = Request(id: "ok", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let respOk = dispatch(request: reqOk, calendarProvider: EmptyListProvider(), authProvider: authOk)
|
||||
XCTAssertNil(respOk.result?.calendars)
|
||||
|
||||
let authDen = NotDeterminedDeniedProvider()
|
||||
let reqDen = Request(id: "den", operation: "calendar.request_full_access", arguments: .object([:]))
|
||||
let respDen = dispatch(request: reqDen, calendarProvider: EmptyListProvider(), authProvider: authDen)
|
||||
// denied has nil result, so no calendars by construction
|
||||
XCTAssertNil(respDen.result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
// TDD for calendar.events.list and calendar.event.create – fake providers only, no live EventKit
|
||||
|
||||
final class CalendarEventsTests: XCTestCase {
|
||||
|
||||
// MARK: - Helper types
|
||||
|
||||
struct FakeCalendarListForSelection: CalendarListProviding {
|
||||
var calendars: [CalendarListItem]
|
||||
func listCalendars() throws -> [CalendarListItem] { calendars }
|
||||
}
|
||||
|
||||
struct FakeEventsProvider: CalendarEventsListProviding {
|
||||
var events: [CalendarEventItem]
|
||||
var shouldThrow: CalendarProviderError? = nil
|
||||
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
if let err = shouldThrow { throw err }
|
||||
var filtered = events
|
||||
if let cid = calendarId {
|
||||
filtered = filtered.filter { $0.calendar_id == cid }
|
||||
} else if let ctitle = calendarTitle {
|
||||
filtered = filtered.filter { $0.calendar_title == ctitle }
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectingEventsProvider: CalendarEventsListProviding {
|
||||
var availableCalendars: [CalendarListItem]
|
||||
var events: [CalendarEventItem]
|
||||
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
if let cid = calendarId {
|
||||
guard availableCalendars.contains(where: { $0.id == cid }) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
|
||||
}
|
||||
return events.filter { $0.calendar_id == cid }.prefix(limit).map { $0 }
|
||||
}
|
||||
if let title = calendarTitle {
|
||||
let matched = availableCalendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
|
||||
}
|
||||
return events.filter { $0.calendar_title == title }.prefix(limit).map { $0 }
|
||||
}
|
||||
let sorted = events.sorted { $0.start < $1.start }
|
||||
return Array(sorted.prefix(limit))
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeCreateProvider: CalendarEventCreateProviding {
|
||||
var shouldThrow: CalendarProviderError? = nil
|
||||
var willReturn: CalendarEventItem
|
||||
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
if let err = shouldThrow { throw err }
|
||||
return willReturn
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectingCreateProvider: CalendarEventCreateProviding {
|
||||
var availableCalendars: [CalendarListItem]
|
||||
var writableIds: Set<String>
|
||||
var willReturn: CalendarEventItem
|
||||
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
if let cid = calendarId {
|
||||
guard let cal = availableCalendars.first(where: { $0.id == cid }) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
|
||||
}
|
||||
guard writableIds.contains(cal.id) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(cal.title)")
|
||||
}
|
||||
return willReturn
|
||||
}
|
||||
if let t = calendarTitle {
|
||||
let matched = availableCalendars.filter { $0.title == t }
|
||||
if matched.isEmpty { throw CalendarProviderError.invalidRequest("Calendar not found: \(t)") }
|
||||
if matched.count > 1 { throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(t) matches \(matched.count) calendars") }
|
||||
guard writableIds.contains(matched[0].id) else {
|
||||
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(matched[0].title)")
|
||||
}
|
||||
return willReturn
|
||||
}
|
||||
throw CalendarProviderError.invalidRequest("Calendar must be specified by id or exact unique title")
|
||||
}
|
||||
}
|
||||
|
||||
struct DeniedEventsProvider: CalendarEventsListProviding {
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
struct FailEventsProvider: CalendarEventsListProviding {
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
|
||||
throw CalendarProviderError.unavailable("disk fail")
|
||||
}
|
||||
}
|
||||
|
||||
struct DeniedCreateProvider: CalendarEventCreateProviding {
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
struct MockAuthProvider: CalendarAuthorizationProviding {
|
||||
var status: CalendarAuthorizationStatus
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
final class CountingCreate: CalendarEventCreateProviding, @unchecked Sendable {
|
||||
var count = 0
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
count += 1
|
||||
return CalendarEventItem(id: "sample", title: "Sample", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: nil, location: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func sampleEvent() -> CalendarEventItem {
|
||||
CalendarEventItem(id: "sample", title: "Sample", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: nil, location: nil)
|
||||
}
|
||||
|
||||
// MARK: - List tests
|
||||
|
||||
func testEventsListMissingStartReturnsInvalidRequest() {
|
||||
let req = Request(id: "e1", operation: "calendar.events.list", arguments: .object(["end": .string("2026-01-02T00:00:00Z")]))
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testEventsListInvalidISODate() {
|
||||
let args: JSONValue = .object(["start": .string("not-a-date"), "end": .string("2026-01-02T00:00:00Z")])
|
||||
let req = Request(id: "e2", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testEventsListStartAfterEnd() {
|
||||
let args: JSONValue = .object(["start": .string("2026-01-03T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z")])
|
||||
let req = Request(id: "e3", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testEventsListLimitBounded() {
|
||||
let argsLow: JSONValue = .object(["start": .string("2026-01-01T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z"), "limit": .number(0)])
|
||||
let reqLow = Request(id: "e4", operation: "calendar.events.list", arguments: argsLow)
|
||||
let respLow = dispatch(request: reqLow, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(respLow.ok)
|
||||
XCTAssertEqual(respLow.error?.code, "invalid_request")
|
||||
|
||||
let argsHigh: JSONValue = .object(["start": .string("2026-01-01T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z"), "limit": .number(251)])
|
||||
let reqHigh = Request(id: "e5", operation: "calendar.events.list", arguments: argsHigh)
|
||||
let respHigh = dispatch(request: reqHigh, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(respHigh.ok)
|
||||
XCTAssertEqual(respHigh.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testEventsListSuccessSortedAndMinimalFields() {
|
||||
let ev1 = CalendarEventItem(id: "2", title: "B", start: "2026-01-01T11:00:00Z", end: "2026-01-01T12:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: "n2", location: "loc2")
|
||||
let ev2 = CalendarEventItem(id: "1", title: "A", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: nil, location: nil)
|
||||
let provider = FakeEventsProvider(events: [ev1, ev2])
|
||||
let args: JSONValue = .object(["start": .string("2026-01-01T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z"), "limit": .number(10)])
|
||||
let req = Request(id: "e6", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.events?.count, 2)
|
||||
XCTAssertEqual(resp.result?.events?.first?.id, "1")
|
||||
let encoded = try! JSONEncoder().encode(resp)
|
||||
let obj = try! JSONSerialization.jsonObject(with: encoded) as! [String: Any]
|
||||
let result = obj["result"] as! [String: Any]
|
||||
let events = result["events"] as! [[String: Any]]
|
||||
for ev in events {
|
||||
XCTAssertNotNil(ev["id"])
|
||||
XCTAssertNotNil(ev["title"])
|
||||
XCTAssertNotNil(ev["start"])
|
||||
XCTAssertNotNil(ev["end"])
|
||||
XCTAssertNotNil(ev["calendar_id"])
|
||||
XCTAssertNotNil(ev["calendar_title"])
|
||||
let allowed = Set(["id","title","start","end","all_day","calendar_id","calendar_title","notes","location"])
|
||||
XCTAssertTrue(Set(ev.keys).isSubset(of: allowed), "Unexpected keys: \(ev.keys)")
|
||||
}
|
||||
}
|
||||
|
||||
func testEventsListCalendarIdWinsOverTitle() {
|
||||
let calendars = [
|
||||
CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav"),
|
||||
CalendarListItem(id: "id2", title: "Home", source: "Local", type: "local")
|
||||
]
|
||||
let ev1 = CalendarEventItem(id: "e1", title: "T", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "id1", calendar_title: "Home", notes: nil, location: nil)
|
||||
let ev2 = CalendarEventItem(id: "e2", title: "T", start: "2026-01-01T11:00:00Z", end: "2026-01-01T12:00:00Z", all_day: false, calendar_id: "id2", calendar_title: "Home", notes: nil, location: nil)
|
||||
let provider = SelectingEventsProvider(availableCalendars: calendars, events: [ev1, ev2])
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z"),
|
||||
"calendar_id": .string("id1"),
|
||||
"calendar": .string("Home"),
|
||||
"limit": .number(10)
|
||||
])
|
||||
let req = Request(id: "e7", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.events?.count, 1)
|
||||
XCTAssertEqual(resp.result?.events?.first?.calendar_id, "id1")
|
||||
}
|
||||
|
||||
func testEventsListUnknownCalendarFailsDeterministic() {
|
||||
let calendars = [CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav")]
|
||||
let provider = SelectingEventsProvider(availableCalendars: calendars, events: [])
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z"),
|
||||
"calendar": .string("Work")
|
||||
])
|
||||
let req = Request(id: "e8", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testEventsListAmbiguousTitleFails() {
|
||||
let calendars = [
|
||||
CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav"),
|
||||
CalendarListItem(id: "id2", title: "Home", source: "Local", type: "local")
|
||||
]
|
||||
let provider = SelectingEventsProvider(availableCalendars: calendars, events: [])
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z"),
|
||||
"calendar": .string("Home")
|
||||
])
|
||||
let req = Request(id: "e9", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
XCTAssertTrue(resp.error?.message.contains("Ambiguous") ?? false)
|
||||
}
|
||||
|
||||
func testEventsListPermissionRequired() {
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z")
|
||||
])
|
||||
let req = Request(id: "e10", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: DeniedEventsProvider(), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
}
|
||||
|
||||
func testEventsListProviderFailureMapsToUnavailable() {
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z")
|
||||
])
|
||||
let req = Request(id: "e11", operation: "calendar.events.list", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FailEventsProvider(), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
|
||||
}
|
||||
|
||||
// MARK: - Create tests
|
||||
|
||||
func testCreateMissingTitleInvalid() {
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1")
|
||||
])
|
||||
let req = Request(id: "c1", operation: "calendar.event.create", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateStartAfterEndInvalid() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T12:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1")
|
||||
])
|
||||
let req = Request(id: "c2", operation: "calendar.event.create", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateInvalidISO() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("bad-date"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1")
|
||||
])
|
||||
let req = Request(id: "c3", operation: "calendar.event.create", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateNoCalendarSpecifiedFailsNoDefault() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z")
|
||||
])
|
||||
let req = Request(id: "c4", operation: "calendar.event.create", arguments: args)
|
||||
let calendars = [CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav")]
|
||||
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: ["id1"], willReturn: sampleEvent())
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
XCTAssertTrue(resp.error?.message.contains("must be specified") ?? false)
|
||||
}
|
||||
|
||||
func testCreateUnknownCalendarFails() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar": .string("Nonexistent")
|
||||
])
|
||||
let req = Request(id: "c5", operation: "calendar.event.create", arguments: args)
|
||||
let calendars = [CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav")]
|
||||
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: ["id1"], willReturn: sampleEvent())
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateAmbiguousTitleFails() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar": .string("Home")
|
||||
])
|
||||
let req = Request(id: "c6", operation: "calendar.event.create", arguments: args)
|
||||
let calendars = [
|
||||
CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav"),
|
||||
CalendarListItem(id: "id2", title: "Home", source: "Local", type: "local")
|
||||
]
|
||||
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: ["id1","id2"], willReturn: sampleEvent())
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateReadOnlyCalendarFails() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1")
|
||||
])
|
||||
let req = Request(id: "c7", operation: "calendar.event.create", arguments: args)
|
||||
let calendars = [CalendarListItem(id: "id1", title: "Birthdays", source: "iCloud", type: "birthday")]
|
||||
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: [], willReturn: sampleEvent())
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateSuccessReturnsMetadata() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1"),
|
||||
"notes": .string("bring docs"),
|
||||
"location": .string("Room 1")
|
||||
])
|
||||
let req = Request(id: "c8", operation: "calendar.event.create", arguments: args)
|
||||
let created = CalendarEventItem(id: "new-id", title: "Meeting", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "id1", calendar_title: "Home", notes: "bring docs", location: "Room 1")
|
||||
let provider = FakeCreateProvider(willReturn: created)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.event?.id, "new-id")
|
||||
XCTAssertEqual(resp.result?.event?.calendar_id, "id1")
|
||||
XCTAssertEqual(resp.result?.operation, "calendar.event.create")
|
||||
}
|
||||
|
||||
func testCreatePermissionRequired() {
|
||||
let args: JSONValue = .object([
|
||||
"title": .string("Meeting"),
|
||||
"start": .string("2026-01-01T10:00:00Z"),
|
||||
"end": .string("2026-01-01T11:00:00Z"),
|
||||
"calendar_id": .string("id1")
|
||||
])
|
||||
let req = Request(id: "c9", operation: "calendar.event.create", arguments: args)
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: DeniedCreateProvider(), authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
}
|
||||
|
||||
func testListDoesNotTriggerCreate() {
|
||||
let args: JSONValue = .object([
|
||||
"start": .string("2026-01-01T00:00:00Z"),
|
||||
"end": .string("2026-01-02T00:00:00Z")
|
||||
])
|
||||
let req = Request(id: "iso", operation: "calendar.events.list", arguments: args)
|
||||
let counter = CountingCreate()
|
||||
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: counter, authProvider: MockAuthProvider(status: .authorized))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(counter.count, 0, "list must not trigger create")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
// RED tests first: TDD for calendar.list migration
|
||||
|
||||
final class CalendarListTests: XCTestCase {
|
||||
|
||||
// MARK: - JSONValue safe representation
|
||||
|
||||
func testJSONValueRoundTripsObjectArrayPrimitive() throws {
|
||||
// Expect JSONValue type to support object/array/string/bool/number/null
|
||||
let json = """
|
||||
{"id":"1","operation":"calendar.list","arguments":{"filter":"home","limit":2,"nested":{"a":1},"arr":[1,2,null,true],"flag":false}}
|
||||
"""
|
||||
let data = json.data(using: .utf8)!
|
||||
let req = try JSONDecoder().decode(Request.self, from: data)
|
||||
// arguments should not be empty struct; should retain values
|
||||
// We test via encoding back and check presence
|
||||
let encoded = try JSONEncoder().encode(req)
|
||||
let obj = try JSONSerialization.jsonObject(with: encoded) as! [String: Any]
|
||||
let args = obj["arguments"] as! [String: Any]
|
||||
XCTAssertEqual(args["filter"] as? String, "home")
|
||||
XCTAssertNotNil(args["nested"])
|
||||
XCTAssertNotNil(args["arr"])
|
||||
}
|
||||
|
||||
func testJSONValueCodableEquatableSendable() throws {
|
||||
// Verify JSONValue conforms to Codable, Equatable, Sendable (compile-time)
|
||||
let v1: JSONValue = .object(["a": .number(1)])
|
||||
let v2: JSONValue = .object(["a": .number(1)])
|
||||
XCTAssertEqual(v1, v2)
|
||||
// Codable roundtrip
|
||||
let data = try JSONEncoder().encode(v1)
|
||||
let decoded = try JSONDecoder().decode(JSONValue.self, from: data)
|
||||
XCTAssertEqual(decoded, v1)
|
||||
}
|
||||
|
||||
// MARK: - CalendarListProvider injection & sorting
|
||||
|
||||
// Fake provider for tests
|
||||
struct FakeSuccessProvider: CalendarListProviding {
|
||||
let calendars: [CalendarListItem]
|
||||
func listCalendars() throws -> [CalendarListItem] { calendars }
|
||||
}
|
||||
|
||||
func testCalendarListSuccessAndDeterministicSort() throws {
|
||||
// Unsorted input should be returned sorted by source/title/id
|
||||
let unsorted = [
|
||||
CalendarListItem(id: "c", title: "B", source: "iCloud", type: "caldav"),
|
||||
CalendarListItem(id: "a", title: "A", source: "Local", type: "local"),
|
||||
CalendarListItem(id: "b", title: "A", source: "iCloud", type: "caldav"),
|
||||
CalendarListItem(id: "aa", title: "A", source: "iCloud", type: "caldav"),
|
||||
]
|
||||
let provider = FakeSuccessProvider(calendars: unsorted)
|
||||
let req = Request(id: "id-1", operation: "calendar.list", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: provider)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.id, "id-1")
|
||||
// The new response/result model should still carry protocol_version/operation/status and calendars
|
||||
// We decode result payload to check calendars order
|
||||
// ResultPayload should support generic calendars? We'll check via JSON
|
||||
let encoded = try JSONEncoder().encode(resp)
|
||||
let obj = try JSONSerialization.jsonObject(with: encoded) as! [String: Any]
|
||||
let result = obj["result"] as! [String: Any]
|
||||
XCTAssertEqual(result["protocol_version"] as? String, PROTOCOL_VERSION)
|
||||
XCTAssertEqual(result["operation"] as? String, "calendar.list")
|
||||
XCTAssertEqual(result["status"] as? String, "ok")
|
||||
let cals = result["calendars"] as! [[String: Any]]
|
||||
// Deterministic sort: by source, then title, then id (lexicographic, case-sensitive)
|
||||
// 'L' (76) < 'i' (105) so Local < iCloud
|
||||
// Expected order: (Local,A,a), (iCloud,A,aa), (iCloud,A,b), (iCloud,B,c)
|
||||
XCTAssertEqual(cals[0]["id"] as? String, "a")
|
||||
XCTAssertEqual(cals[1]["id"] as? String, "aa")
|
||||
XCTAssertEqual(cals[2]["id"] as? String, "b")
|
||||
XCTAssertEqual(cals[3]["id"] as? String, "c")
|
||||
// Ensure only allowed fields
|
||||
for cal in cals {
|
||||
XCTAssertEqual(Set(cal.keys), Set(["id","title","source","type"]))
|
||||
}
|
||||
}
|
||||
|
||||
func testCalendarListPermissionRequired() throws {
|
||||
struct DeniedProvider: CalendarListProviding {
|
||||
func listCalendars() throws -> [CalendarListItem] {
|
||||
throw CalendarProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
let req = Request(id: "perm-1", operation: "calendar.list", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: DeniedProvider())
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
XCTAssertNil(resp.result, "permission_required must not leak calendar content")
|
||||
}
|
||||
|
||||
func testCalendarListProviderFailure() throws {
|
||||
struct FailProvider: CalendarListProviding {
|
||||
func listCalendars() throws -> [CalendarListItem] {
|
||||
throw CalendarProviderError.unavailable("disk error")
|
||||
}
|
||||
}
|
||||
let req = Request(id: "fail-1", operation: "calendar.list", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: FailProvider())
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
|
||||
}
|
||||
|
||||
func testCalendarListRequestJSONDecodeWithArgumentsObject() throws {
|
||||
let json = """
|
||||
{"id":"json-1","operation":"calendar.list","arguments":{"foo":"bar","num":42}}
|
||||
"""
|
||||
let data = json.data(using: .utf8)!
|
||||
let req = try JSONDecoder().decode(Request.self, from: data)
|
||||
XCTAssertEqual(req.id, "json-1")
|
||||
// arguments should be parsed and not throw
|
||||
let provider = FakeSuccessProvider(calendars: [])
|
||||
let resp = dispatch(request: req, calendarProvider: provider)
|
||||
XCTAssertTrue(resp.ok, "calendar.list with args object should succeed")
|
||||
}
|
||||
|
||||
func testServiceHealthStillWorksAfterMigration() throws {
|
||||
let req = Request(id: "health-1", operation: "service.health", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: FakeSuccessProvider(calendars: []))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION)
|
||||
XCTAssertEqual(resp.result?.operation, "service.health")
|
||||
XCTAssertEqual(resp.result?.status, "ok")
|
||||
}
|
||||
|
||||
// MARK: - EventKit provider must be read-only (static check)
|
||||
|
||||
func testProductionEventKitProviderNeverRequestsAccess() throws {
|
||||
// Read the EventKit provider source and ensure it never calls prompt-triggering APIs
|
||||
// Note: createEvent legitimately mutates (save) – allowed only inside createEvent method.
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var providerURL: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHost/CalendarProvider.swift")
|
||||
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
|
||||
let candCore = cur.appendingPathComponent("Sources/ReynaCLIHostCore/CalendarProvider.swift")
|
||||
if fm.fileExists(atPath: candCore.path) { providerURL = candCore; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost/CalendarProvider.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
|
||||
let cand2Core = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/CalendarProvider.swift")
|
||||
if fm.fileExists(atPath: cand2Core.path) { providerURL = cand2Core; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = providerURL, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("CalendarProvider.swift not found for read-only safety check")
|
||||
return
|
||||
}
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
for line in lines {
|
||||
let l = line.lowercased()
|
||||
if l.contains("ekeventstore") && l.contains(".request") {
|
||||
XCTFail("Production provider must not call request methods on EKEventStore: \(line)")
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(content.contains("authorizationStatus"), "Should check authorizationStatus")
|
||||
// Ensure no remove mutation anywhere
|
||||
let lower = content.lowercased()
|
||||
XCTAssertFalse(lower.contains("remove(") && lower.contains("ekevent"), "Should not remove EKEvent")
|
||||
// No AppleScript
|
||||
XCTAssertFalse(lower.contains("nsapplescript") || lower.contains("appleevent"), "Should not use AppleScript")
|
||||
// If save exists, it must be inside createEvent func (mutation allowed only there)
|
||||
if lower.contains("save(") {
|
||||
// crude check: ensure save appears after func createEvent
|
||||
let parts = content.components(separatedBy: "func createEvent")
|
||||
XCTAssertEqual(parts.count, 2, "save should only appear in createEvent, found multiple or none")
|
||||
let beforeCreate = parts[0].lowercased()
|
||||
XCTAssertFalse(beforeCreate.contains("save(") && beforeCreate.contains("ekevent"), "save(EKEvent) must not appear outside createEvent")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
final class ContactsAuthorizationTests: XCTestCase {
|
||||
|
||||
struct AlreadyAuthorizedProvider: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
|
||||
func requestAccess() throws -> Bool {
|
||||
XCTFail("must not call requestAccess when already authorized")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct NotDeterminedGranted: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined }
|
||||
func requestAccess() throws -> Bool { true }
|
||||
}
|
||||
|
||||
struct NotDeterminedDenied: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined }
|
||||
func requestAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct DeniedProvider: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .denied }
|
||||
func requestAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct ErrorProvider: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined }
|
||||
func requestAccess() throws -> Bool {
|
||||
throw ContactsProviderError.unavailable("disk error")
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyContactsSearch: ContactsSearchProviding {
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { [] }
|
||||
}
|
||||
|
||||
struct EmptyContactsRead: ContactsReadProviding {
|
||||
func readContact(id: String) throws -> ContactDetailItem {
|
||||
throw ContactsProviderError.notFound("not found")
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyContactsCreate: ContactsCreateProviding {
|
||||
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
|
||||
throw ContactsProviderError.unavailable("no create")
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyCalendarList: CalendarListProviding {
|
||||
func listCalendars() throws -> [CalendarListItem] { [] }
|
||||
}
|
||||
|
||||
func testAlreadyAuthorizedNoRequest() {
|
||||
let auth = AlreadyAuthorizedProvider()
|
||||
let req = Request(id: "c-a1", operation: "contacts.request_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertNil(resp.result?.contacts, "auth response must not leak contacts")
|
||||
XCTAssertNil(resp.result?.contact)
|
||||
}
|
||||
|
||||
func testNotDeterminedGrantedReturnsAuthorized() {
|
||||
let auth = NotDeterminedGranted()
|
||||
let req = Request(id: "c-a2", operation: "contacts.request_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertEqual(resp.result?.operation, "contacts.request_access")
|
||||
}
|
||||
|
||||
func testDeniedReturnsPermissionDenied() {
|
||||
let auth = NotDeterminedDenied()
|
||||
let req = Request(id: "c-a3", operation: "contacts.request_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||||
}
|
||||
|
||||
func testErrorReturnsContactsUnavailable() {
|
||||
let auth = ErrorProvider()
|
||||
let req = Request(id: "c-a4", operation: "contacts.request_access", arguments: .object([:]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "contacts_unavailable")
|
||||
}
|
||||
|
||||
func testContactsSearchDoesNotCallAuthRequest() {
|
||||
final class SpySearch: ContactsSearchProviding, @unchecked Sendable {
|
||||
var called = false
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] {
|
||||
called = true
|
||||
return []
|
||||
}
|
||||
}
|
||||
final class SpyAuth: ContactsAuthorizationProviding, @unchecked Sendable {
|
||||
var didRequest = false
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
|
||||
func requestAccess() throws -> Bool {
|
||||
didRequest = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
let search = SpySearch()
|
||||
let cAuth = SpyAuth()
|
||||
let req = Request(id: "cs-1", operation: "contacts.search", arguments: .object(["query": .string("john"), "limit": .number(10)]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: cAuth, contactsSearchProvider: search, contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertFalse(cAuth.didRequest, "contacts.search must never trigger authorization request")
|
||||
XCTAssertTrue(search.called)
|
||||
}
|
||||
|
||||
// Bridging tests reuse same bridge pattern – verify contacts bridge pumps run loop
|
||||
func testContactsBridgePumpsMainRunLoop() {
|
||||
let exp = expectation(description: "contacts bridge")
|
||||
final class Box: @unchecked Sendable { var granted = false; var error: Error? = nil }
|
||||
let box = Box()
|
||||
DispatchQueue.main.async {
|
||||
let bridge = ContactsMainRunLoopBridge()
|
||||
do {
|
||||
box.granted = try bridge.requestAccess(timeout: 2) { completion in
|
||||
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
|
||||
completion(true, nil)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
box.error = error
|
||||
}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 5)
|
||||
XCTAssertNil(box.error)
|
||||
XCTAssertTrue(box.granted)
|
||||
}
|
||||
|
||||
// MARK: - Helpers shared
|
||||
|
||||
struct FakeEventsProvider: CalendarEventsListProviding {
|
||||
var events: [CalendarEventItem]
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { events }
|
||||
}
|
||||
|
||||
struct FakeCreateProvider: CalendarEventCreateProviding {
|
||||
var willReturn: CalendarEventItem
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem { willReturn }
|
||||
}
|
||||
|
||||
func sampleEvent() -> CalendarEventItem {
|
||||
CalendarEventItem(id: "sample", title: "Sample", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: nil, location: nil)
|
||||
}
|
||||
|
||||
struct MockCalAuth: CalendarAuthorizationProviding {
|
||||
var status: CalendarAuthorizationStatus
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
// Isolation: only ContactsAuthorizationProvider.swift should call requestAccess(for: .contacts)
|
||||
func testOnlyContactsAuthorizationProviderCallsRequestAccessForContacts() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var dirs: [URL] = []
|
||||
for _ in 0..<10 {
|
||||
let candCore = cur.appendingPathComponent("Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: candCore.path) { dirs.append(candCore); break }
|
||||
let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: cand.path) { dirs.append(cand); break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let srcDir = dirs.first else {
|
||||
XCTFail("Could not find ReynaCLIHostCore sources"); return
|
||||
}
|
||||
let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? []
|
||||
var hits: [String] = []
|
||||
for file in files where file.pathExtension == "swift" {
|
||||
guard let content = try? String(contentsOf: file) else { continue }
|
||||
if content.contains("requestAccess(for:") && file.lastPathComponent != "ContactsAuthorizationProvider.swift" {
|
||||
// Calendar provider calls requestFullAccessToEvents – not contacts
|
||||
if content.contains(".contacts") || content.contains("CNContact") {
|
||||
hits.append(file.lastPathComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(hits.isEmpty, "Only ContactsAuthorizationProvider.swift should call requestAccess(for: .contacts), found extras: \(hits)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
final class ContactsOperationsTests: XCTestCase {
|
||||
|
||||
// MARK: - Fake providers
|
||||
|
||||
struct FakeSearch: ContactsSearchProviding {
|
||||
var contacts: [ContactListItem]
|
||||
var shouldThrow: ContactsProviderError? = nil
|
||||
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] {
|
||||
if let e = shouldThrow { throw e }
|
||||
var filtered = contacts
|
||||
if let q = query, !q.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let lower = q.lowercased()
|
||||
filtered = filtered.filter { ($0.name + "\n" + $0.organization).lowercased().contains(lower) }
|
||||
}
|
||||
filtered.sort { $0.name < $1.name }
|
||||
if filtered.count > limit { filtered = Array(filtered.prefix(limit)) }
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeRead: ContactsReadProviding {
|
||||
var contact: ContactDetailItem?
|
||||
var shouldThrow: ContactsProviderError? = nil
|
||||
func readContact(id: String) throws -> ContactDetailItem {
|
||||
if let e = shouldThrow { throw e }
|
||||
guard let c = contact, c.id == id else {
|
||||
throw ContactsProviderError.notFound("Contact not found: \(id)")
|
||||
}
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeCreate: ContactsCreateProviding {
|
||||
var result: ContactCreateResult
|
||||
var shouldThrow: ContactsProviderError? = nil
|
||||
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
|
||||
if let e = shouldThrow { throw e }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyCalendarList: CalendarListProviding {
|
||||
func listCalendars() throws -> [CalendarListItem] { [] }
|
||||
}
|
||||
|
||||
struct EmptyEvents: CalendarEventsListProviding {
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { [] }
|
||||
}
|
||||
|
||||
struct EmptyCreate: CalendarEventCreateProviding {
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
CalendarEventItem(id: "x", title: "t", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "c", calendar_title: "Home", notes: nil, location: nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyContactsAuth: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
|
||||
func requestAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
func mockCalAuth() -> MockCalendarAuth { MockCalendarAuth(status: .authorized) }
|
||||
|
||||
struct MockCalendarAuth: CalendarAuthorizationProviding {
|
||||
var status: CalendarAuthorizationStatus
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
// Helpers to build dispatch for contacts only
|
||||
func dispatchContacts(request: Request, search: ContactsSearchProviding = FakeSearch(contacts: []), read: ContactsReadProviding = FakeRead(), create: ContactsCreateProviding = FakeCreate(result: ContactCreateResult(id: "id", name: "Name", organization: "")), auth: ContactsAuthorizationProviding = EmptyContactsAuth()) -> Response {
|
||||
return dispatch(request: request, calendarProvider: EmptyCalendarList(), eventsProvider: EmptyEvents(), createProvider: EmptyCreate(), authProvider: mockCalAuth(), contactsAuthProvider: auth, contactsSearchProvider: search, contactsReadProvider: read, contactsCreateProvider: create)
|
||||
}
|
||||
|
||||
func sampleContactList() -> [ContactListItem] {
|
||||
[
|
||||
ContactListItem(id: "3", name: "Charlie", organization: "OrgC", modifiedAt: "2026-01-01T00:00:00Z"),
|
||||
ContactListItem(id: "1", name: "Alice", organization: "OrgA", modifiedAt: "2026-01-01T00:00:00Z"),
|
||||
ContactListItem(id: "2", name: "Bob", organization: "OrgB", modifiedAt: "2026-01-01T00:00:00Z"),
|
||||
]
|
||||
}
|
||||
|
||||
func sampleContactDetail() -> ContactDetailItem {
|
||||
ContactDetailItem(id: "1", name: "Alice Smith", firstName: "Alice", lastName: "Smith", organization: "OrgA", jobTitle: "Engineer", emails: [ContactEmailLabelValue(label: "work", value: "alice@example.com")], phones: [ContactPhoneLabelValue(label: "mobile", value: "123")], modifiedAt: "2026-01-01T00:00:00Z")
|
||||
}
|
||||
|
||||
// MARK: - Search tests
|
||||
|
||||
func testSearchMissingArgsReturnsAllWithDefaultLimit() {
|
||||
let req = Request(id: "s1", operation: "contacts.search", arguments: .object([:]))
|
||||
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList()))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.contacts?.count, 3)
|
||||
}
|
||||
|
||||
func testSearchLimitBoundedLow() {
|
||||
let req = Request(id: "s2", operation: "contacts.search", arguments: .object(["limit": .number(0)]))
|
||||
let resp = dispatchContacts(request: req)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testSearchLimitBoundedHigh() {
|
||||
let req = Request(id: "s3", operation: "contacts.search", arguments: .object(["limit": .number(101)]))
|
||||
let resp = dispatchContacts(request: req)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testSearchQueryFiltersDeterministically() {
|
||||
let req = Request(id: "s4", operation: "contacts.search", arguments: .object(["query": .string("ali"), "limit": .number(10)]))
|
||||
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList()))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.contacts?.count, 1)
|
||||
XCTAssertEqual(resp.result?.contacts?.first?.name, "Alice")
|
||||
}
|
||||
|
||||
func testSearchSortedByNameOrgId() {
|
||||
let req = Request(id: "s5", operation: "contacts.search", arguments: .object(["limit": .number(10)]))
|
||||
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList()))
|
||||
XCTAssertTrue(resp.ok)
|
||||
let ids = resp.result?.contacts?.map { $0.id }
|
||||
XCTAssertEqual(ids, ["1","2","3"])
|
||||
}
|
||||
|
||||
func testSearchPermissionRequired() {
|
||||
let req = Request(id: "s6", operation: "contacts.search", arguments: .object([:]))
|
||||
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: [], shouldThrow: .permissionRequired))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
XCTAssertNil(resp.result)
|
||||
}
|
||||
|
||||
func testSearchContactsNeverPrompts() throws {
|
||||
// Ensure Contacts search providers never call request-access APIs – static source check
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var providerURL: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = providerURL, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("ContactsProvider.swift not found"); return
|
||||
}
|
||||
// Protocol declarations like `func requestAccess()` are allowed; actual prompt calls use `requestAccess(for:`
|
||||
// or call on CNContactStore. We forbid `requestAccess(for:` in this file.
|
||||
XCTAssertFalse(content.contains("requestAccess(for:"), "Contacts search/read/create must not call requestAccess(for:) – only auth provider should")
|
||||
XCTAssertFalse(content.contains("requestFullAccess"), "Contacts provider must not call calendar request")
|
||||
}
|
||||
|
||||
// MARK: - Read tests
|
||||
|
||||
func testReadMissingIdInvalidRequest() {
|
||||
let req = Request(id: "r1", operation: "contacts.read", arguments: .object([:]))
|
||||
let resp = dispatchContacts(request: req)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testReadSuccessMinimalFields() {
|
||||
let detail = sampleContactDetail()
|
||||
let req = Request(id: "r2", operation: "contacts.read", arguments: .object(["id": .string("1")]))
|
||||
let resp = dispatchContacts(request: req, read: FakeRead(contact: detail))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.contact?.id, "1")
|
||||
XCTAssertEqual(resp.result?.contact?.firstName, "Alice")
|
||||
XCTAssertEqual(resp.result?.contact?.emails.first?.value, "alice@example.com")
|
||||
}
|
||||
|
||||
func testReadPermissionRequired() {
|
||||
let req = Request(id: "r3", operation: "contacts.read", arguments: .object(["id": .string("1")]))
|
||||
let resp = dispatchContacts(request: req, read: FakeRead(shouldThrow: .permissionRequired))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
}
|
||||
|
||||
func testReadNotFoundMapsToInvalid() {
|
||||
let req = Request(id: "r4", operation: "contacts.read", arguments: .object(["id": .string("nope")]))
|
||||
let resp = dispatchContacts(request: req, read: FakeRead(contact: sampleContactDetail()))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
// MARK: - Create tests
|
||||
|
||||
func testCreateMissingNameFieldsInvalid() {
|
||||
let req = Request(id: "c1", operation: "contacts.create", arguments: .object([:]))
|
||||
let resp = dispatchContacts(request: req)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "invalid_request")
|
||||
}
|
||||
|
||||
func testCreateSuccessReturnsMetadataOnly() {
|
||||
let result = ContactCreateResult(id: "new-id", name: "Alice Smith", organization: "OrgA")
|
||||
let req = Request(id: "c2", operation: "contacts.create", arguments: .object(["firstName": .string("Alice"), "lastName": .string("Smith")]))
|
||||
let resp = dispatchContacts(request: req, create: FakeCreate(result: result))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.created_contact?.id, "new-id")
|
||||
XCTAssertEqual(resp.result?.created_contact?.name, "Alice Smith")
|
||||
// Ensure no excessive fields leaked
|
||||
let encoded = try! JSONEncoder().encode(resp)
|
||||
let obj = try! JSONSerialization.jsonObject(with: encoded) as! [String: Any]
|
||||
let resultObj = obj["result"] as! [String: Any]
|
||||
XCTAssertNotNil(resultObj["created_contact"])
|
||||
XCTAssertNil(resultObj["contact"], "create must not output full contact detail")
|
||||
XCTAssertNil(resultObj["contacts"])
|
||||
}
|
||||
|
||||
func testCreateWithEmailPhoneObjects() {
|
||||
let result = ContactCreateResult(id: "nid", name: "Bob", organization: "")
|
||||
let req = Request(id: "c3", operation: "contacts.create", arguments: .object([
|
||||
"firstName": .string("Bob"),
|
||||
"email": .object(["label": .string("work"), "value": .string("bob@example.com")]),
|
||||
"phone": .object(["label": .string("mobile"), "value": .string("+1555")])
|
||||
]))
|
||||
let resp = dispatchContacts(request: req, create: FakeCreate(result: result))
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.created_contact?.name, "Bob")
|
||||
}
|
||||
|
||||
func testCreatePermissionRequired() {
|
||||
let req = Request(id: "c4", operation: "contacts.create", arguments: .object(["firstName": .string("Bob")]))
|
||||
let resp = dispatchContacts(request: req, create: FakeCreate(result: ContactCreateResult(id: "x", name: "x", organization: ""), shouldThrow: .permissionRequired))
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_required")
|
||||
}
|
||||
|
||||
func testSearchDoesNotTriggerCreate() {
|
||||
final class CountingCreate: ContactsCreateProviding, @unchecked Sendable {
|
||||
var count = 0
|
||||
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
|
||||
count += 1
|
||||
return ContactCreateResult(id: "x", name: "x", organization: "")
|
||||
}
|
||||
}
|
||||
let counter = CountingCreate()
|
||||
let req = Request(id: "iso", operation: "contacts.search", arguments: .object(["limit": .number(5)]))
|
||||
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: EmptyEvents(), createProvider: EmptyCreate(), authProvider: mockCalAuth(), contactsAuthProvider: EmptyContactsAuth(), contactsSearchProvider: FakeSearch(contacts: []), contactsReadProvider: FakeRead(), contactsCreateProvider: counter)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(counter.count, 0, "search must not trigger create")
|
||||
}
|
||||
|
||||
// MARK: - Regression: contacts.search production crash (CNPropertyNotFetchedException)
|
||||
|
||||
func testSearchProductionDoesNotUseCNContactFormatter() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var providerURL: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = providerURL, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("ContactsProvider.swift not found"); return
|
||||
}
|
||||
XCTAssertFalse(content.contains("CNContactFormatter.string("), "Production ContactsProvider must not call CNContactFormatter.string - use only fetched keys to avoid ObjC exception on middleName etc.")
|
||||
XCTAssertFalse(content.contains("CNContactMiddleNameKey"), "Do not add middleName to keysToFetch - fix is to avoid formatter, not fetch more")
|
||||
XCTAssertFalse(content.contains("CNContactNamePrefixKey"), "Avoid extra keys to satisfy formatter")
|
||||
XCTAssertFalse(content.contains("CNContactNameSuffixKey"), "Avoid extra keys to satisfy formatter")
|
||||
XCTAssertFalse(content.contains("CNContactNicknameKey"), "Avoid extra keys to satisfy formatter")
|
||||
}
|
||||
|
||||
func testSearchProductionKeysToFetchWhitelist() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var providerURL: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = providerURL, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("ContactsProvider.swift not found"); return
|
||||
}
|
||||
let lines = content.components(separatedBy: "\n")
|
||||
guard let searchIdx = lines.firstIndex(where: { $0.contains("struct ContactsSearchProvider") }) else {
|
||||
XCTFail("ContactsSearchProvider not found"); return
|
||||
}
|
||||
let searchSlice = lines[searchIdx..<min(searchIdx+30, lines.count)].joined(separator: "\n")
|
||||
XCTAssertTrue(searchSlice.contains("CNContactIdentifierKey"), "search should fetch identifier")
|
||||
XCTAssertTrue(searchSlice.contains("CNContactGivenNameKey"), "search should fetch givenName")
|
||||
XCTAssertTrue(searchSlice.contains("CNContactFamilyNameKey"), "search should fetch familyName")
|
||||
XCTAssertTrue(searchSlice.contains("CNContactOrganizationNameKey"), "search should fetch org for filter")
|
||||
XCTAssertFalse(searchSlice.contains("CNContactEmailAddressesKey"), "search must not fetch emails")
|
||||
XCTAssertFalse(searchSlice.contains("CNContactPhoneNumbersKey"), "search must not fetch phones")
|
||||
XCTAssertFalse(searchSlice.contains("CNContactMiddleNameKey"), "search must not fetch middleName")
|
||||
}
|
||||
|
||||
func testSearchNonmatchingQueryProducesEmptyResultDeterministically() {
|
||||
func displayNameFromFetchedParts(givenName: String, familyName: String) -> String {
|
||||
let combined = "\(givenName) \(familyName)".trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return combined.components(separatedBy: .whitespaces).filter { !$0.isEmpty }.joined(separator: " ")
|
||||
}
|
||||
let contacts = [
|
||||
ContactListItem(id: "1", name: displayNameFromFetchedParts(givenName: "Alice", familyName: "Smith"), organization: "OrgA", modifiedAt: ""),
|
||||
ContactListItem(id: "2", name: displayNameFromFetchedParts(givenName: "Bob", familyName: "Jones"), organization: "OrgB", modifiedAt: ""),
|
||||
]
|
||||
let fake = FakeSearch(contacts: contacts)
|
||||
let syntheticQuery = "zzzz_synthetic_nonmatch_9f3a7c2e"
|
||||
let filtered = try! fake.searchContacts(query: syntheticQuery, limit: 20)
|
||||
XCTAssertEqual(filtered.count, 0, "Synthetic nonmatching query should yield empty result, not crash")
|
||||
|
||||
let req = Request(id: "s-nm", operation: "contacts.search", arguments: .object(["query": .string(syntheticQuery), "limit": .number(20)]))
|
||||
let resp = dispatchContacts(request: req, search: fake)
|
||||
XCTAssertTrue(resp.ok, "Nonmatching search must succeed with ok:true")
|
||||
XCTAssertEqual(resp.result?.contacts?.count, 0, "Nonmatching search must return empty list")
|
||||
}
|
||||
|
||||
func testReadProductionDoesNotUseCNContactFormatter() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var providerURL: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = providerURL, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("ContactsProvider.swift not found"); return
|
||||
}
|
||||
XCTAssertFalse(content.contains("CNContactFormatter.string("), "Read must not use CNContactFormatter either")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
|
||||
/// Executable-level integration tests invoking the compiled ReynaCLIHost binary.
|
||||
/// These prove persistent-pipe and malformed-request behavior.
|
||||
final class HostIntegrationTests: XCTestCase {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func hostExecutableURL() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
// When `swift test` runs, cwd is package root. But be robust.
|
||||
// Portable candidates only: package-relative .build locations for common triples.
|
||||
let candidates: [String] = [
|
||||
".build/debug/ReynaCLIHost",
|
||||
".build/arm64-apple-macosx/debug/ReynaCLIHost",
|
||||
".build/x86_64-apple-macosx/debug/ReynaCLIHost",
|
||||
]
|
||||
let cwd = fm.currentDirectoryPath
|
||||
var tried: [String] = []
|
||||
for c in candidates {
|
||||
let url = URL(fileURLWithPath: cwd).appendingPathComponent(c)
|
||||
tried.append(url.path)
|
||||
if fm.isExecutableFile(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
// Try surrounding .build directories walked upward from cwd
|
||||
var parent = URL(fileURLWithPath: cwd)
|
||||
for _ in 0..<6 {
|
||||
let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost")
|
||||
tried.append(p1.path)
|
||||
if fm.isExecutableFile(atPath: p1.path) { return p1 }
|
||||
let p2 = parent.appendingPathComponent(".build/arm64-apple-macosx/debug/ReynaCLIHost")
|
||||
tried.append(p2.path)
|
||||
if fm.isExecutableFile(atPath: p2.path) { return p2 }
|
||||
parent = parent.deletingLastPathComponent()
|
||||
}
|
||||
throw NSError(domain: "HostIntegrationTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "ReynaCLIHost binary not found. Tried:\n" + tried.joined(separator: "\n")])
|
||||
}
|
||||
|
||||
/// Run host with given stdin string, return stdout lines (non-empty trimmed) after process exits.
|
||||
func runHost(input: String, timeout: TimeInterval = 5) throws -> [String] {
|
||||
let exe = try hostExecutableURL()
|
||||
let process = Process()
|
||||
process.executableURL = exe
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardInput = stdinPipe
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
try process.run()
|
||||
|
||||
// Write input then close
|
||||
if let data = input.data(using: .utf8) {
|
||||
stdinPipe.fileHandleForWriting.write(data)
|
||||
}
|
||||
stdinPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
// Wait with timeout
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while process.isRunning && Date() < deadline {
|
||||
usleep(100_000) // 0.1s
|
||||
}
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
throw NSError(domain: "HostIntegrationTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host process timed out after \(timeout)s. stderr: \(String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "")"])
|
||||
}
|
||||
|
||||
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let outStr = String(data: outData, encoding: .utf8) ?? ""
|
||||
// Split by newline, keep non-empty raw lines but preserve for debugging
|
||||
let lines = outStr.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) }.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
return lines
|
||||
}
|
||||
|
||||
func decodeResponse(_ line: String) throws -> [String: Any] {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw NSError(domain: "HostIntegrationTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Line is not valid JSON: \(line)"])
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
func testPersistentPipeHandlesTwoHealthRequests() throws {
|
||||
// Two well-formed health requests on persistent stdin must yield two responses.
|
||||
let req1 = #"{"id":"1","operation":"service.health","arguments":{}}"#
|
||||
let req2 = #"{"id":"2","operation":"service.health","arguments":{}}"#
|
||||
let input = req1 + "\n" + req2 + "\n"
|
||||
let lines = try runHost(input: input)
|
||||
XCTAssertEqual(lines.count, 2, "Expected 2 responses for 2 requests, got \(lines.count). Output: \(lines)")
|
||||
|
||||
let resp1 = try decodeResponse(lines[0])
|
||||
XCTAssertEqual(resp1["id"] as? String, "1")
|
||||
XCTAssertEqual(resp1["ok"] as? Bool, true)
|
||||
|
||||
let resp2 = try decodeResponse(lines[1])
|
||||
XCTAssertEqual(resp2["id"] as? String, "2")
|
||||
XCTAssertEqual(resp2["ok"] as? Bool, true)
|
||||
}
|
||||
|
||||
func testMalformedJsonProducesInvalidRequestResponseWithoutId() throws {
|
||||
// Malformed nonempty JSON must produce a response with ok:false, error.code invalid_request, id = ""
|
||||
let bad = "not json at all"
|
||||
let input = bad + "\n"
|
||||
let lines = try runHost(input: input)
|
||||
XCTAssertEqual(lines.count, 1, "Malformed JSON should produce one error response, got \(lines.count). Output: \(lines)")
|
||||
|
||||
let resp = try decodeResponse(lines[0])
|
||||
XCTAssertEqual(resp["ok"] as? Bool, false, "Malformed JSON should be ok:false")
|
||||
XCTAssertEqual(resp["id"] as? String, "", "When id cannot be recovered, id should be empty string")
|
||||
if let err = resp["error"] as? [String: Any] {
|
||||
XCTAssertEqual(err["code"] as? String, "invalid_request")
|
||||
} else {
|
||||
XCTFail("Missing error object in response: \(resp)")
|
||||
}
|
||||
}
|
||||
|
||||
func testMalformedJsonPreservesIdWhenPossible() throws {
|
||||
// When malformed JSON still contains an id field, preserve it.
|
||||
let bad = #"{"id":"keep-me","operation":}"# // invalid JSON but id extractable
|
||||
let input = bad + "\n"
|
||||
let lines = try runHost(input: input)
|
||||
XCTAssertEqual(lines.count, 1, "Expected 1 error response for malformed JSON with id, got \(lines)")
|
||||
let resp = try decodeResponse(lines[0])
|
||||
XCTAssertEqual(resp["ok"] as? Bool, false)
|
||||
XCTAssertEqual(resp["id"] as? String, "keep-me", "Should preserve id when recoverable")
|
||||
if let err = resp["error"] as? [String: Any] {
|
||||
XCTAssertEqual(err["code"] as? String, "invalid_request")
|
||||
} else {
|
||||
XCTFail("Missing error object")
|
||||
}
|
||||
}
|
||||
|
||||
func testEmptyLinesAreIgnored() throws {
|
||||
// Empty lines should not produce responses or break subsequent messages.
|
||||
let req1 = #"{"id":"a","operation":"service.health","arguments":{}}"#
|
||||
let req2 = #"{"id":"b","operation":"service.health","arguments":{}}"#
|
||||
let input = "\n" + req1 + "\n\n\n" + req2 + "\n\n"
|
||||
let lines = try runHost(input: input)
|
||||
XCTAssertEqual(lines.count, 2, "Empty lines should be ignored, expected 2 responses got \(lines.count): \(lines)")
|
||||
let ids = try lines.map { try decodeResponse($0)["id"] as? String }
|
||||
XCTAssertEqual(ids, ["a", "b"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
import Darwin
|
||||
@testable import ReynaCLIHostCore
|
||||
|
||||
final class LStatFailClosedTests: XCTestCase {
|
||||
private func dir(uid: uid_t, mode: mode_t, symlink: Bool = false) -> LStatInfo {
|
||||
return LStatInfo(uid: uid, mode: mode_t(mode), isSymlink: symlink, isDir: !symlink, exists: true)
|
||||
}
|
||||
private func file(uid: uid_t, mode: mode_t) -> LStatInfo {
|
||||
// regular file: not symlink, not dir
|
||||
return LStatInfo(uid: uid, mode: mode_t(mode), isSymlink: false, isDir: false, exists: true)
|
||||
}
|
||||
private func currentUID() -> uid_t { getuid() }
|
||||
|
||||
// RED: EACCES and ELOOP must not be treated as missing (fail-closed)
|
||||
func testNonENOENTProviderFailureRejectsWithCode22() {
|
||||
let uid = currentUID()
|
||||
let socketPath = "/tmp/rhfail/reyna.sock"
|
||||
// Map only root trusted; but intermediate component will fail with EACCES
|
||||
var mapPresent: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/private": dir(uid: 0, mode: 0o40755),
|
||||
"/private/tmp": dir(uid: 0, mode: 0o41777),
|
||||
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true)
|
||||
]
|
||||
let provider: LStatResultProvider = { p in
|
||||
if p == "/tmp/rhfail" {
|
||||
return .failed(errnoCode: EACCES)
|
||||
}
|
||||
if let v = mapPresent[p] { return .present(v) }
|
||||
return .absent
|
||||
}
|
||||
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: provider)) { err in
|
||||
XCTAssertEqual((err as NSError).code, 22, "EACCES must surface as lstat failure code 22, not absent")
|
||||
}
|
||||
|
||||
let providerLoop: LStatResultProvider = { p in
|
||||
if p == "/tmp/rhfail" { return .failed(errnoCode: ELOOP) }
|
||||
if let v = mapPresent[p] { return .present(v) }
|
||||
return .absent
|
||||
}
|
||||
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: providerLoop)) { err in
|
||||
XCTAssertEqual((err as NSError).code, 22, "ELOOP must surface as code 22, not treated as ENOENT")
|
||||
}
|
||||
}
|
||||
|
||||
func testEAccesAnywhereInChainRejects() {
|
||||
let uid = currentUID()
|
||||
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
|
||||
let provider: LStatResultProvider = { p in
|
||||
if p == "/Users" { return .failed(errnoCode: EACCES) }
|
||||
return .absent
|
||||
}
|
||||
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: provider))
|
||||
}
|
||||
|
||||
// RED: regular file at /tmp or /var must be rejected (old ensureParentDirectories had bug where it skipped directory check for those)
|
||||
func testRegularFileAtTmpMustReject() throws {
|
||||
let uid = currentUID()
|
||||
let socketPath = "/tmp/reyna.sock"
|
||||
// /tmp exists as regular file (not dir, not symlink)
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/tmp": file(uid: 0, mode: 0o100644) // regular file
|
||||
]
|
||||
let provider: LStatResultProvider = { p in
|
||||
if let v = map[p] { return .present(v) }
|
||||
return .absent
|
||||
}
|
||||
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: provider), "Regular file at /tmp must reject (not directory)")
|
||||
// Also test direct single-component validator
|
||||
XCTAssertThrowsError(try validateSingleLStatInfoOrThrow(path: "/tmp", info: map["/tmp"]!, currentUID: uid))
|
||||
XCTAssertThrowsError(try validateSingleLStatInfoOrThrow(path: "/var", info: file(uid: 0, mode: 0o100644), currentUID: uid))
|
||||
}
|
||||
|
||||
func testRegularFileAtIntermediateTrustedAliasMustReject() {
|
||||
let uid = currentUID()
|
||||
// /private/var exists as file
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/private": dir(uid: 0, mode: 0o40755),
|
||||
"/private/var": file(uid: 0, mode: 0o100644)
|
||||
]
|
||||
let provider: LStatResultProvider = { p in
|
||||
if let v = map[p] { return .present(v) }
|
||||
return .absent
|
||||
}
|
||||
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: "/private/var/tmp/x/reyna.sock", currentUID: uid, provider: provider))
|
||||
}
|
||||
|
||||
func testLiveProviderDoesNotSwallowNonENOENT() {
|
||||
// liveLStatProvider legacy should now fail-closed sentinel, not nil
|
||||
// Simulate by calling wrapper directly: we can't easily force EACCES without real FS,
|
||||
// but we can assert that failed case in result provider is distinct from absent
|
||||
let absent = LStatResult.absent
|
||||
let failed = LStatResult.failed(errnoCode: EACCES)
|
||||
switch absent {
|
||||
case .absent: break
|
||||
default: XCTFail()
|
||||
}
|
||||
switch failed {
|
||||
case .failed(let c): XCTAssertEqual(c, EACCES)
|
||||
default: XCTFail()
|
||||
}
|
||||
// legacy provider should return non-nil sentinel for failed case (so caller doesn't treat as missing)
|
||||
// We test sentinel is non-nil and will be rejected by validator
|
||||
// The new liveLStatResultProvider is tested via chain above
|
||||
}
|
||||
|
||||
func testTmpSymlinkStillAllowed() throws {
|
||||
let uid = currentUID()
|
||||
let map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/private": dir(uid: 0, mode: 0o40755),
|
||||
"/private/tmp": dir(uid: 0, mode: 0o41777),
|
||||
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true),
|
||||
"/tmp/rh-test": dir(uid: uid, mode: 0o40700)
|
||||
]
|
||||
let provider: LStatResultProvider = { p in
|
||||
if let v = map[p] { return .present(v) }
|
||||
return .absent
|
||||
}
|
||||
XCTAssertNoThrow(try validateParentChainPureResultProvider(socketPath: "/tmp/rh-test/reyna.sock", currentUID: uid, provider: provider))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
|
||||
final class ProtocolTests: XCTestCase {
|
||||
|
||||
func testServiceHealthReturnsOKWithSameIdAndProtocolVersion() throws {
|
||||
let req = Request(id: "x", operation: "service.health", arguments: Args())
|
||||
let resp = dispatch(request: req)
|
||||
|
||||
XCTAssertEqual(resp.id, "x", "response must echo same id")
|
||||
XCTAssertTrue(resp.ok, "service.health should be ok:true")
|
||||
XCTAssertNotNil(resp.result, "result must be present on success")
|
||||
XCTAssertEqual(resp.result?.operation, "service.health")
|
||||
XCTAssertFalse(resp.result?.protocol_version.isEmpty ?? true, "protocol_version must be nonempty")
|
||||
XCTAssertNil(resp.error, "error must be nil on success")
|
||||
}
|
||||
|
||||
func testServiceHealthDecodedFromJSON() throws {
|
||||
let json = #"{"id":"abc-123","operation":"service.health","arguments":{}}"#
|
||||
let data = json.data(using: .utf8)!
|
||||
let decoder = JSONDecoder()
|
||||
let req = try decoder.decode(Request.self, from: data)
|
||||
|
||||
let resp = dispatch(request: req)
|
||||
|
||||
XCTAssertEqual(resp.id, "abc-123")
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.operation, "service.health")
|
||||
XCTAssertFalse(resp.result?.protocol_version.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func testUnknownOperationReturnsError() throws {
|
||||
let req = Request(id: "y", operation: "does.not.exist", arguments: Args())
|
||||
let resp = dispatch(request: req)
|
||||
|
||||
XCTAssertEqual(resp.id, "y")
|
||||
XCTAssertFalse(resp.ok, "unknown operation must return ok:false")
|
||||
XCTAssertNil(resp.result, "result must be nil on failure")
|
||||
XCTAssertNotNil(resp.error)
|
||||
XCTAssertEqual(resp.error?.code, "unknown_operation")
|
||||
}
|
||||
|
||||
func testUnknownOperationJSONRoundTrip() throws {
|
||||
let json = #"{"id":"1","operation":"foo.bar","arguments":{}}"#
|
||||
let req = try JSONDecoder().decode(Request.self, from: json.data(using: .utf8)!)
|
||||
let resp = dispatch(request: req)
|
||||
let encoded = try JSONEncoder().encode(resp)
|
||||
let decoded = try JSONDecoder().decode(Response.self, from: encoded)
|
||||
|
||||
XCTAssertEqual(decoded.id, "1")
|
||||
XCTAssertFalse(decoded.ok)
|
||||
XCTAssertEqual(decoded.error?.code, "unknown_operation")
|
||||
}
|
||||
|
||||
func testDispatchIsDeterministic() throws {
|
||||
let req = Request(id: "same", operation: "service.health", arguments: Args())
|
||||
let r1 = dispatch(request: req)
|
||||
let r2 = dispatch(request: req)
|
||||
XCTAssertEqual(r1.id, r2.id)
|
||||
XCTAssertEqual(r1.ok, r2.ok)
|
||||
XCTAssertEqual(r1.result?.protocol_version, r2.result?.protocol_version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
|
||||
final class PythonLauncherTests: XCTestCase {
|
||||
func testConfiguredRuntimeUsesEnvironmentBeforeConfigAndDefaults() throws {
|
||||
let home = URL(fileURLWithPath: "/tmp/reyna-home")
|
||||
let config = [
|
||||
"REYNA_CLI_DIR": "/config/repo",
|
||||
"REYNA_CLI_PYTHON": "/config/python",
|
||||
]
|
||||
let environment = [
|
||||
"REYNA_CLI_DIR": "/environment/repo",
|
||||
"REYNA_CLI_PYTHON": "/environment/python",
|
||||
]
|
||||
|
||||
let runtime = resolvePythonRuntime(
|
||||
homeDirectory: home,
|
||||
environment: environment,
|
||||
config: config,
|
||||
isExecutable: { path in path == "/environment/python" }
|
||||
)
|
||||
|
||||
XCTAssertEqual(runtime.workingDirectory.path, "/environment/repo")
|
||||
XCTAssertEqual(runtime.python.path, "/environment/python")
|
||||
}
|
||||
|
||||
func testConfiguredRuntimeFallsBackToSystemPythonWhenSelectedPythonMissing() throws {
|
||||
let runtime = resolvePythonRuntime(
|
||||
homeDirectory: URL(fileURLWithPath: "/tmp/reyna-home"),
|
||||
environment: [:],
|
||||
config: ["REYNA_CLI_DIR": "/config/repo", "REYNA_CLI_PYTHON": "/missing/python"],
|
||||
isExecutable: { _ in false }
|
||||
)
|
||||
|
||||
XCTAssertEqual(runtime.workingDirectory.path, "/config/repo")
|
||||
XCTAssertEqual(runtime.python.path, "/usr/bin/python3")
|
||||
}
|
||||
|
||||
func testPythonCommandRunsFixedCliModuleAndForwardsArguments() {
|
||||
let runtime = PythonRuntime(
|
||||
workingDirectory: URL(fileURLWithPath: "/repo"),
|
||||
python: URL(fileURLWithPath: "/repo/.venv/bin/python")
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
pythonCommand(runtime: runtime, forwardedArguments: ["local-services", "speech", "generate", "hello"]),
|
||||
["/repo/.venv/bin/python", "-m", "reyna_cli.cli", "local-services", "speech", "generate", "hello"]
|
||||
)
|
||||
}
|
||||
|
||||
func testPythonEnvironmentRemovesParentInterpreterOverrides() {
|
||||
let environment = pythonLaunchEnvironment(from: [
|
||||
"PATH": "/bin",
|
||||
"PYTHONHOME": "/wrong",
|
||||
"PYTHONPATH": "/wrong/site-packages",
|
||||
"VIRTUAL_ENV": "/wrong/.venv",
|
||||
"KEEP": "yes",
|
||||
])
|
||||
|
||||
XCTAssertNil(environment["PYTHONHOME"])
|
||||
XCTAssertNil(environment["PYTHONPATH"])
|
||||
XCTAssertNil(environment["VIRTUAL_ENV"])
|
||||
XCTAssertEqual(environment["PYTHONNOUSERSITE"], "1")
|
||||
XCTAssertEqual(environment["KEEP"], "yes")
|
||||
}
|
||||
|
||||
func testConfigParserIgnoresCommentsBlankLinesAndQuotes() {
|
||||
let config = parsePythonRuntimeConfig("# comment\nREYNA_CLI_DIR = '/repo path'\n\nREYNA_CLI_PYTHON=\"/python\"\ninvalid\n")
|
||||
|
||||
XCTAssertEqual(config["REYNA_CLI_DIR"], "/repo path")
|
||||
XCTAssertEqual(config["REYNA_CLI_PYTHON"], "/python")
|
||||
XCTAssertNil(config["invalid"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
import Foundation
|
||||
|
||||
// Tests for reminders.request_full_access – TDD, fake providers only
|
||||
// Modeled after CalendarAuthorizationTests.swift
|
||||
|
||||
final class RemindersAuthorizationTests: XCTestCase {
|
||||
|
||||
// MARK: - Fake auth providers
|
||||
|
||||
struct AlreadyAuthorizedProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
XCTFail("requestFullAccess must not be called when already authorized")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct NotDeterminedGrantedProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool { true }
|
||||
}
|
||||
|
||||
struct NotDeterminedDeniedProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct DeniedProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .denied }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
|
||||
struct TimeoutProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
throw RemindersProviderError.unavailable("reminders authorization timed out")
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorProvider: RemindersAuthorizationProviding {
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
throw RemindersProviderError.unavailable("disk error")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty reminders providers for dispatch
|
||||
|
||||
struct EmptyLists: RemindersListsProviding {
|
||||
func listReminderLists() throws -> [ReminderListItem] { [] }
|
||||
}
|
||||
|
||||
struct EmptyList: RemindersListProviding {
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { [] }
|
||||
}
|
||||
|
||||
struct EmptyCreate: RemindersCreateProviding {
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
|
||||
ReminderCreateResult(id: "r1", list_id: "l1", list_title: "t", title: title)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyCalList: CalendarListProviding { func listCalendars() throws -> [CalendarListItem] { [] } }
|
||||
struct EmptyCalEvents: CalendarEventsListProviding {
|
||||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { [] }
|
||||
}
|
||||
struct EmptyCalCreate: CalendarEventCreateProviding {
|
||||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||||
CalendarEventItem(id: "e", title: title, start: "2026-01-01T00:00:00Z", end: "2026-01-01T01:00:00Z", all_day: false, calendar_id: "c", calendar_title: "t", notes: nil, location: nil)
|
||||
}
|
||||
}
|
||||
struct MockCalAuth: CalendarAuthorizationProviding {
|
||||
var status: CalendarAuthorizationStatus = .authorized
|
||||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool { false }
|
||||
}
|
||||
struct EmptyContactsAuth: ContactsAuthorizationProviding {
|
||||
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
|
||||
func requestAccess() throws -> Bool { false }
|
||||
}
|
||||
struct EmptyContactsSearch: ContactsSearchProviding { func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { [] } }
|
||||
struct EmptyContactsRead: ContactsReadProviding {
|
||||
func readContact(id: String) throws -> ContactDetailItem { throw ContactsProviderError.notFound("nf") }
|
||||
}
|
||||
struct EmptyContactsCreate: ContactsCreateProviding {
|
||||
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
|
||||
throw ContactsProviderError.unavailable("na")
|
||||
}
|
||||
}
|
||||
|
||||
private func dispatchRemindersAuth(op: String, id: String, auth: RemindersAuthorizationProviding) -> Response {
|
||||
let req = Request(id: id, operation: op, arguments: .object([:]))
|
||||
return dispatch(
|
||||
request: req,
|
||||
calendarProvider: EmptyCalList(),
|
||||
eventsProvider: EmptyCalEvents(),
|
||||
createProvider: EmptyCalCreate(),
|
||||
authProvider: MockCalAuth(),
|
||||
contactsAuthProvider: EmptyContactsAuth(),
|
||||
contactsSearchProvider: EmptyContactsSearch(),
|
||||
contactsReadProvider: EmptyContactsRead(),
|
||||
contactsCreateProvider: EmptyContactsCreate(),
|
||||
remindersAuthProvider: auth,
|
||||
remindersListsProvider: EmptyLists(),
|
||||
remindersListProvider: EmptyList(),
|
||||
remindersCreateProvider: EmptyCreate()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Core auth behavior
|
||||
|
||||
func testAlreadyAuthorizedReturnsAuthorizedWithoutRequesting() {
|
||||
let auth = AlreadyAuthorizedProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra1", auth: auth)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertEqual(resp.result?.operation, "reminders.request_full_access")
|
||||
XCTAssertEqual(resp.id, "ra1")
|
||||
}
|
||||
|
||||
func testNotDeterminedReachesRequestPath() {
|
||||
final class TrackingProvider: RemindersAuthorizationProviding, @unchecked Sendable {
|
||||
var didRequest = false
|
||||
var status: RemindersAuthorizationStatus = .notDetermined
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { status }
|
||||
func requestFullAccess() throws -> Bool {
|
||||
didRequest = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
let tracking = TrackingProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra2", auth: tracking)
|
||||
XCTAssertTrue(tracking.didRequest, "requestFullAccess must be called when notDetermined")
|
||||
XCTAssertTrue(resp.ok)
|
||||
}
|
||||
|
||||
func testGrantedReturnsAuthorizedResult() {
|
||||
let auth = NotDeterminedGrantedProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra3", auth: auth)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertEqual(resp.result?.status, "authorized")
|
||||
XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION)
|
||||
XCTAssertNil(resp.error)
|
||||
XCTAssertNil(resp.result?.reminders, "must not output reminder content")
|
||||
XCTAssertNil(resp.result?.reminder_lists)
|
||||
XCTAssertNil(resp.result?.reminder)
|
||||
}
|
||||
|
||||
func testDeniedReturnsPermissionDenied() {
|
||||
let auth = NotDeterminedDeniedProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra4", auth: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||||
XCTAssertNotNil(resp.error?.message)
|
||||
XCTAssertNil(resp.result)
|
||||
}
|
||||
|
||||
func testAlreadyDeniedPathAlsoDenies() {
|
||||
let auth = DeniedProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra5", auth: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||||
}
|
||||
|
||||
func testTimeoutReturnsRemindersUnavailable() {
|
||||
let auth = TimeoutProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra6", auth: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "reminders_unavailable")
|
||||
XCTAssertTrue(resp.error?.message.lowercased().contains("timed out") ?? false)
|
||||
}
|
||||
|
||||
func testErrorReturnsRemindersUnavailable() {
|
||||
let auth = ErrorProvider()
|
||||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra7", auth: auth)
|
||||
XCTAssertFalse(resp.ok)
|
||||
XCTAssertEqual(resp.error?.code, "reminders_unavailable")
|
||||
}
|
||||
|
||||
func testNoReminderDataInAuthResponses() {
|
||||
let authOk = NotDeterminedGrantedProvider()
|
||||
let respOk = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ok", auth: authOk)
|
||||
XCTAssertNil(respOk.result?.reminders)
|
||||
XCTAssertNil(respOk.result?.reminder_lists)
|
||||
XCTAssertNil(respOk.result?.reminder)
|
||||
XCTAssertNil(respOk.result?.created_reminder)
|
||||
XCTAssertNil(respOk.result?.calendars)
|
||||
XCTAssertNil(respOk.result?.events)
|
||||
|
||||
let authDen = NotDeterminedDeniedProvider()
|
||||
let respDen = dispatchRemindersAuth(op: "reminders.request_full_access", id: "den", auth: authDen)
|
||||
XCTAssertNil(respDen.result)
|
||||
}
|
||||
|
||||
// MARK: - list / create must never prompt
|
||||
|
||||
func testRemindersListsDoesNotCallAuthRequest() {
|
||||
final class SpyLists: RemindersListsProviding, @unchecked Sendable {
|
||||
var called = false
|
||||
func listReminderLists() throws -> [ReminderListItem] { called = true; return [] }
|
||||
}
|
||||
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
|
||||
var didCallStatus = false
|
||||
var didCallRequest = false
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { didCallStatus = true; return .authorized }
|
||||
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
|
||||
}
|
||||
let lists = SpyLists()
|
||||
let auth = SpyAuth()
|
||||
let req = Request(id: "rl-1", operation: "reminders.lists", arguments: .object([:]))
|
||||
let resp = dispatch(
|
||||
request: req,
|
||||
calendarProvider: EmptyCalList(),
|
||||
eventsProvider: EmptyCalEvents(),
|
||||
createProvider: EmptyCalCreate(),
|
||||
authProvider: MockCalAuth(),
|
||||
contactsAuthProvider: EmptyContactsAuth(),
|
||||
contactsSearchProvider: EmptyContactsSearch(),
|
||||
contactsReadProvider: EmptyContactsRead(),
|
||||
contactsCreateProvider: EmptyContactsCreate(),
|
||||
remindersAuthProvider: auth,
|
||||
remindersListsProvider: lists,
|
||||
remindersListProvider: EmptyList(),
|
||||
remindersCreateProvider: EmptyCreate()
|
||||
)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertFalse(auth.didCallRequest, "reminders.lists must never call requestFullAccess")
|
||||
XCTAssertTrue(lists.called)
|
||||
}
|
||||
|
||||
func testRemindersListDoesNotCallAuthRequest() {
|
||||
final class SpyList: RemindersListProviding, @unchecked Sendable {
|
||||
var called = false
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { called = true; return [] }
|
||||
}
|
||||
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
|
||||
var didCallRequest = false
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
|
||||
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
|
||||
}
|
||||
let rl = SpyList()
|
||||
let auth = SpyAuth()
|
||||
let req = Request(id: "r-1", operation: "reminders.list", arguments: .object(["limit": .number(10)]))
|
||||
let resp = dispatch(
|
||||
request: req,
|
||||
calendarProvider: EmptyCalList(),
|
||||
eventsProvider: EmptyCalEvents(),
|
||||
createProvider: EmptyCalCreate(),
|
||||
authProvider: MockCalAuth(),
|
||||
contactsAuthProvider: EmptyContactsAuth(),
|
||||
contactsSearchProvider: EmptyContactsSearch(),
|
||||
contactsReadProvider: EmptyContactsRead(),
|
||||
contactsCreateProvider: EmptyContactsCreate(),
|
||||
remindersAuthProvider: auth,
|
||||
remindersListsProvider: EmptyLists(),
|
||||
remindersListProvider: rl,
|
||||
remindersCreateProvider: EmptyCreate()
|
||||
)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertFalse(auth.didCallRequest, "reminders.list must never trigger authorization request")
|
||||
XCTAssertTrue(rl.called)
|
||||
}
|
||||
|
||||
func testRemindersCreateDoesNotCallAuthRequest() {
|
||||
final class SpyCreate: RemindersCreateProviding, @unchecked Sendable {
|
||||
var called = false
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
|
||||
called = true
|
||||
return ReminderCreateResult(id: "x", list_id: "l", list_title: "t", title: title)
|
||||
}
|
||||
}
|
||||
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
|
||||
var didCallRequest = false
|
||||
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
|
||||
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
|
||||
}
|
||||
let create = SpyCreate()
|
||||
let auth = SpyAuth()
|
||||
let req = Request(id: "rc-1", operation: "reminders.create", arguments: .object(["title": .string("Buy milk"), "list": .string("Groceries")]))
|
||||
let resp = dispatch(
|
||||
request: req,
|
||||
calendarProvider: EmptyCalList(),
|
||||
eventsProvider: EmptyCalEvents(),
|
||||
createProvider: EmptyCalCreate(),
|
||||
authProvider: MockCalAuth(),
|
||||
contactsAuthProvider: EmptyContactsAuth(),
|
||||
contactsSearchProvider: EmptyContactsSearch(),
|
||||
contactsReadProvider: EmptyContactsRead(),
|
||||
contactsCreateProvider: EmptyContactsCreate(),
|
||||
remindersAuthProvider: auth,
|
||||
remindersListsProvider: EmptyLists(),
|
||||
remindersListProvider: EmptyList(),
|
||||
remindersCreateProvider: create
|
||||
)
|
||||
XCTAssertTrue(resp.ok)
|
||||
XCTAssertFalse(auth.didCallRequest, "reminders.create must never trigger authorization request (permission check is via status only in real provider, but here we assert no prompt)")
|
||||
XCTAssertTrue(create.called)
|
||||
}
|
||||
|
||||
// MARK: - Bridge tests – deterministic pump without real EventKit
|
||||
|
||||
final class TestBox<T>: @unchecked Sendable {
|
||||
var value: T
|
||||
init(_ v: T) { value = v }
|
||||
}
|
||||
|
||||
func testBridgePumpsMainRunLoopAndDeliversMainQueueCallback() throws {
|
||||
let exp = expectation(description: "bridge completes")
|
||||
let grantedBox = TestBox(false)
|
||||
let errorBox = TestBox<Error?>(nil)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let bridge = RemindersMainRunLoopBridge()
|
||||
do {
|
||||
grantedBox.value = try bridge.requestAccess(timeout: 2) { completion in
|
||||
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
|
||||
completion(true, nil)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorBox.value = error
|
||||
}
|
||||
exp.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [exp], timeout: 5)
|
||||
XCTAssertNil(errorBox.value, "bridge must not timeout when it pumps main run loop; got \(String(describing: errorBox.value))")
|
||||
XCTAssertTrue(grantedBox.value, "granted should be true after main-queue callback is pumped")
|
||||
}
|
||||
|
||||
func testBridgeHandlesCompletionExactlyOnce() throws {
|
||||
let exp = expectation(description: "exactly once")
|
||||
let resultBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = RemindersMainRunLoopBridge()
|
||||
do {
|
||||
resultBox.value = try bridge.requestAccess(timeout: 1) { completion in
|
||||
completion(true, nil)
|
||||
completion(false, NSError(domain: "should-be-ignored", code: 1))
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(resultBox.value, "First completion should win")
|
||||
}
|
||||
|
||||
func testBridgeThreadSafetyForConcurrentCompletion() throws {
|
||||
let exp = expectation(description: "thread-safe")
|
||||
let grantedBox = TestBox(false)
|
||||
let doneBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = RemindersMainRunLoopBridge()
|
||||
do {
|
||||
grantedBox.value = try bridge.requestAccess(timeout: 1) { completion in
|
||||
DispatchQueue.global().async { completion(true, nil) }
|
||||
DispatchQueue.global().async { completion(false, nil) }
|
||||
}
|
||||
doneBox.value = true
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(doneBox.value, "bridge must complete even with concurrent completions")
|
||||
}
|
||||
|
||||
func testBridgePropagatesError() throws {
|
||||
let exp = expectation(description: "error propagation")
|
||||
let caughtBox = TestBox(false)
|
||||
DispatchQueue.main.async {
|
||||
let bridge = RemindersMainRunLoopBridge()
|
||||
do {
|
||||
_ = try bridge.requestAccess(timeout: 1) { completion in
|
||||
completion(false, NSError(domain: "test", code: 2, userInfo: [NSLocalizedDescriptionKey: "fake EK error"]))
|
||||
}
|
||||
} catch let err as RemindersProviderError {
|
||||
if case .unavailable(let msg) = err {
|
||||
caughtBox.value = msg.contains("fake EK error")
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(caughtBox.value, "Error from EK completion must be wrapped as reminders_unavailable")
|
||||
}
|
||||
|
||||
func testBridgeTimeoutReturnsCorrectError() throws {
|
||||
let exp = expectation(description: "timeout")
|
||||
let codeBox = TestBox("")
|
||||
DispatchQueue.main.async {
|
||||
let bridge = RemindersMainRunLoopBridge()
|
||||
do {
|
||||
_ = try bridge.requestAccess(timeout: 0.15) { _ in }
|
||||
XCTFail("Should have thrown")
|
||||
} catch let err as RemindersProviderError {
|
||||
if case .unavailable(let msg) = err {
|
||||
codeBox.value = msg
|
||||
}
|
||||
} catch {}
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 2)
|
||||
XCTAssertTrue(codeBox.value.lowercased().contains("timed out"), "Timeout must produce 'reminders authorization timed out' message, got \(codeBox.value)")
|
||||
}
|
||||
|
||||
// MARK: - Production code location check
|
||||
|
||||
func testOnlyOneFileCallsRequestFullAccessToReminders() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var dirs: [URL] = []
|
||||
for _ in 0..<10 {
|
||||
let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: cand.path) { dirs.append(cand); break }
|
||||
let cand2 = cur.appendingPathComponent("Sources/ReynaCLIHostCore")
|
||||
if fm.fileExists(atPath: cand2.path) { dirs.append(cand2); break }
|
||||
let cand3 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost")
|
||||
if fm.fileExists(atPath: cand3.path) { dirs.append(cand3) }
|
||||
let cand4 = cur.appendingPathComponent("Sources/ReynaCLIHost")
|
||||
if fm.fileExists(atPath: cand4.path) { dirs.append(cand4) }
|
||||
if !dirs.isEmpty { break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard !dirs.isEmpty else {
|
||||
XCTFail("Could not locate Sources/ReynaCLIHost dir")
|
||||
return
|
||||
}
|
||||
var hits: [String] = []
|
||||
for srcDir in dirs {
|
||||
let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? []
|
||||
for file in files where file.pathExtension == "swift" {
|
||||
guard let content = try? String(contentsOf: file) else { continue }
|
||||
if content.contains("requestFullAccessToReminders") {
|
||||
hits.append(file.lastPathComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
let uniqueSorted = Array(Set(hits)).sorted()
|
||||
XCTAssertEqual(uniqueSorted, ["RemindersAuthorizationProvider.swift"], "requestFullAccessToReminders must only appear in RemindersAuthorizationProvider.swift, found in \(uniqueSorted)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
// Mirrors the pure auth decision we expect to exist in SocketServer.swift after fix.
|
||||
func referenceIsPeerAuthorized(peerUID: uid_t, currentUID: uid_t) -> Bool {
|
||||
return peerUID == currentUID
|
||||
}
|
||||
|
||||
final class SecurityHardeningTests: XCTestCase {
|
||||
|
||||
func hostExecutableURL() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
let candidates = [
|
||||
".build/debug/ReynaCLIHost",
|
||||
".build/arm64-apple-macosx/debug/ReynaCLIHost",
|
||||
".build/x86_64-apple-macosx/debug/ReynaCLIHost",
|
||||
]
|
||||
let cwd = fm.currentDirectoryPath
|
||||
var tried: [String] = []
|
||||
for c in candidates {
|
||||
let url = URL(fileURLWithPath: cwd).appendingPathComponent(c)
|
||||
tried.append(url.path)
|
||||
if fm.isExecutableFile(atPath: url.path) { return url }
|
||||
}
|
||||
var parent = URL(fileURLWithPath: cwd)
|
||||
for _ in 0..<6 {
|
||||
let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost")
|
||||
tried.append(p1.path)
|
||||
if fm.isExecutableFile(atPath: p1.path) { return p1 }
|
||||
parent = parent.deletingLastPathComponent()
|
||||
}
|
||||
throw NSError(domain: "SecurityHardeningTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "binary not found Tried:\n"+tried.joined(separator: "\n")])
|
||||
}
|
||||
|
||||
func makeShortUniqueDirChecked() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
for _ in 0..<20 {
|
||||
let hex = String(format: "%08x", UInt32.random(in: 0...UInt32.max))
|
||||
let url = URL(fileURLWithPath: "/tmp/rh-\(hex)")
|
||||
if !fm.fileExists(atPath: url.path) { return url }
|
||||
}
|
||||
return URL(fileURLWithPath: "/tmp/rh-\(String(format: "%08x", UInt32.random(in: 0...UInt32.max)))")
|
||||
}
|
||||
|
||||
final class HostProcess {
|
||||
let process: Process
|
||||
let socketPath: String
|
||||
let tempDir: URL
|
||||
init(process: Process, socketPath: String, tempDir: URL) {
|
||||
self.process = process; self.socketPath = socketPath; self.tempDir = tempDir
|
||||
}
|
||||
func terminate() {
|
||||
if process.isRunning { process.terminate() }
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while process.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if process.isRunning { process.interrupt() }
|
||||
}
|
||||
deinit { terminate() }
|
||||
}
|
||||
|
||||
func startSocketHost(socketPath: String, tempDir: URL) throws -> HostProcess {
|
||||
let exe = try hostExecutableURL()
|
||||
let process = Process()
|
||||
process.executableURL = exe
|
||||
process.arguments = ["--socket", socketPath]
|
||||
let stderr = Pipe()
|
||||
process.standardError = stderr
|
||||
process.standardOutput = Pipe()
|
||||
process.standardInput = Pipe()
|
||||
try process.run()
|
||||
let fm = FileManager.default
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while Date() < deadline {
|
||||
if fm.fileExists(atPath: socketPath) { break }
|
||||
if !process.isRunning {
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let s = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "SecurityHardeningTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host exited early. stderr: \(s)"])
|
||||
}
|
||||
usleep(100_000)
|
||||
}
|
||||
if !fm.fileExists(atPath: socketPath) {
|
||||
process.terminate()
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let s = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "SecurityHardeningTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Socket not created at \(socketPath). stderr: \(s)"])
|
||||
}
|
||||
return HostProcess(process: process, socketPath: socketPath, tempDir: tempDir)
|
||||
}
|
||||
|
||||
func socketRequestResponse(socketPath: String, requestLine: String, timeout: TimeInterval = 3) throws -> String {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw NSError(domain: "SecurityHardeningTests", code: 10, userInfo: nil) }
|
||||
defer { close(fd) }
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = socketPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dstPtr in
|
||||
dstPtr.withMemoryRebound(to: CChar.self, capacity: 104) { charPtr in
|
||||
strncpy(charPtr, cStr, 103)
|
||||
}
|
||||
}
|
||||
}
|
||||
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let cr = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
|
||||
connect(fd, saddr, addrLen)
|
||||
}
|
||||
}
|
||||
guard cr == 0 else { throw NSError(domain: "SecurityHardeningTests", code: 11, userInfo: [NSLocalizedDescriptionKey: "connect failed: \(String(cString: strerror(errno)))"]) }
|
||||
let toSend = requestLine.hasSuffix("\n") ? requestLine : requestLine + "\n"
|
||||
guard let data = toSend.data(using: .utf8) else { throw NSError(domain: "SecurityHardeningTests", code: 12, userInfo: nil) }
|
||||
var sent = 0
|
||||
while sent < data.count {
|
||||
let n = data.withUnsafeBytes { raw in send(fd, raw.baseAddress!.advanced(by: sent), data.count - sent, 0) }
|
||||
if n <= 0 { throw NSError(domain: "SecurityHardeningTests", code: 13, userInfo: nil) }
|
||||
sent += n
|
||||
}
|
||||
var responseData = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
let start = Date()
|
||||
while true {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
throw NSError(domain: "SecurityHardeningTests", code: 14, userInfo: [NSLocalizedDescriptionKey: "read timeout"])
|
||||
}
|
||||
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let pr = poll(&pfd, 1, 200)
|
||||
if pr < 0 { if errno == EINTR { continue }; throw NSError(domain: "SecurityHardeningTests", code: 15, userInfo: nil) }
|
||||
if pr == 0 { continue }
|
||||
let r = recv(fd, &buffer, buffer.count, 0)
|
||||
if r < 0 { if errno == EINTR { continue }; throw NSError(domain: "SecurityHardeningTests", code: 16, userInfo: nil) }
|
||||
if r == 0 { break }
|
||||
responseData.append(contentsOf: buffer[0..<r])
|
||||
if let str = String(data: responseData, encoding: .utf8), str.contains("\n") { break }
|
||||
}
|
||||
guard let respString = String(data: responseData, encoding: .utf8) else { throw NSError(domain: "SecurityHardeningTests", code: 17, userInfo: nil) }
|
||||
let first = respString.split(separator: "\n", omittingEmptySubsequences: false).first.map { String($0) } ?? respString
|
||||
return first.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// MARK: - 1) peer UID pure decision
|
||||
|
||||
func testPeerAuthorizationPureDecision() {
|
||||
let me = getuid()
|
||||
let foreign: uid_t = (me == 0) ? 1 : 0
|
||||
XCTAssertTrue(referenceIsPeerAuthorized(peerUID: me, currentUID: me), "own UID should be authorized")
|
||||
XCTAssertFalse(referenceIsPeerAuthorized(peerUID: foreign, currentUID: me), "foreign UID should be rejected")
|
||||
}
|
||||
|
||||
func testHostPeerAuthorizationFunctionExists() throws {
|
||||
// If implementation exposes isPeerAuthorized, test it indirectly by exercising server.
|
||||
// We assert current process connecting is allowed (same UID) – existing health test proves this.
|
||||
// For this TDD RED, we also attempt to check source contains getpeereid.
|
||||
let fm = FileManager.default
|
||||
let srcURL = URL(fileURLWithPath: fm.currentDirectoryPath).appendingPathComponent("Sources/ReynaCLIHostCore/SocketServer.swift")
|
||||
// Walk up
|
||||
var found: URL? = nil
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand.path) { found = cand; break }
|
||||
let candOld = cur.appendingPathComponent("Sources/ReynaCLIHost/SocketServer.swift")
|
||||
if fm.fileExists(atPath: candOld.path) { found = candOld; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { found = cand2; break }
|
||||
let cand2Old = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand2Old.path) { found = cand2Old; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
let url = found ?? srcURL
|
||||
guard let content = try? String(contentsOf: url) else {
|
||||
XCTFail("Could not read SocketServer.swift at \(url.path)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(content.contains("getpeereid") || content.contains("getpeerid"), "SocketServer.swift must call getpeereid for peer UID check")
|
||||
}
|
||||
|
||||
// MARK: - 2) signal-handler safety
|
||||
|
||||
func testSignalHandlerNoUnsafeGlobals() throws {
|
||||
let fm = FileManager.default
|
||||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
var srcPath: URL? = nil
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand.path) { srcPath = cand; break }
|
||||
let candOld = cur.appendingPathComponent("Sources/ReynaCLIHost/SocketServer.swift")
|
||||
if fm.fileExists(atPath: candOld.path) { srcPath = candOld; break }
|
||||
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand2.path) { srcPath = cand2; break }
|
||||
let cand2Old = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost/SocketServer.swift")
|
||||
if fm.fileExists(atPath: cand2Old.path) { srcPath = cand2Old; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
guard let url = srcPath, let content = try? String(contentsOf: url) else {
|
||||
XCTFail("Cannot find SocketServer.swift for signal safety check")
|
||||
return
|
||||
}
|
||||
// No Swift mutable global storing path
|
||||
XCTAssertFalse(content.contains("gSocketPathCStr"), "Should not have Swift mutable global gSocketPathCStr")
|
||||
XCTAssertFalse(content.contains("nonisolated(unsafe)"), "Should not have nonisolated(unsafe) global for signal handling")
|
||||
// No unsafeBitCast to sig_t
|
||||
XCTAssertFalse(content.contains("unsafeBitCast") && content.contains("sig_t"), "Should not use unsafeBitCast to sig_t")
|
||||
// Signal handler itself should not be Swift using stat/lstat
|
||||
// Check that reynaSocketSignalHandler Swift func with lstat/stat is gone
|
||||
// Allow C file to handle signals; here check that Swift file doesn't define reynaSocketSignalHandler with lstat
|
||||
// This part will pass when we move handler to C target.
|
||||
// Also check Package.swift contains C target
|
||||
var pkgURL: URL? = nil
|
||||
cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||||
for _ in 0..<8 {
|
||||
let cand = cur.appendingPathComponent("Package.swift")
|
||||
if fm.fileExists(atPath: cand.path) { pkgURL = cand; break }
|
||||
cur = cur.deletingLastPathComponent()
|
||||
}
|
||||
if let purl = pkgURL, (try? String(contentsOf: purl)) != nil {
|
||||
// Should contain C target for signal support OR no signal unsafe patterns above already covers
|
||||
// Not failing if C target missing yet, but signal safety tests still need to show RED via earlier checks
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 3) path validation
|
||||
|
||||
func testSocketPathRejectsDotDotComponents() throws {
|
||||
// Host should refuse paths containing .. or . components
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
let dotDotPath = unique.appendingPathComponent("../evil.sock").path
|
||||
// This contains .. – should be rejected, process exits non-zero quickly
|
||||
let exe = try hostExecutableURL()
|
||||
let proc = Process()
|
||||
proc.executableURL = exe
|
||||
proc.arguments = ["--socket", dotDotPath]
|
||||
proc.standardError = Pipe()
|
||||
proc.standardOutput = Pipe()
|
||||
try proc.run()
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while proc.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
XCTFail("Host should reject path containing .. and exit, but kept running for \(dotDotPath)")
|
||||
} else {
|
||||
XCTAssertNotEqual(proc.terminationStatus, 0, "Should exit non-zero for .. path")
|
||||
}
|
||||
// Also test ./ component
|
||||
let dotPath = unique.appendingPathComponent("./evil.sock").path
|
||||
let proc2 = Process()
|
||||
proc2.executableURL = exe
|
||||
proc2.arguments = ["--socket", dotPath]
|
||||
proc2.standardError = Pipe()
|
||||
proc2.standardOutput = Pipe()
|
||||
try proc2.run()
|
||||
let deadline2 = Date().addingTimeInterval(2)
|
||||
while proc2.isRunning && Date() < deadline2 { usleep(100_000) }
|
||||
if proc2.isRunning {
|
||||
proc2.terminate()
|
||||
XCTFail("Host should reject path containing . and exit")
|
||||
} else {
|
||||
XCTAssertNotEqual(proc2.terminationStatus, 0, "Should exit non-zero for . path")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketParentRejectsSymlink() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
let real = unique.appendingPathComponent("real")
|
||||
try fm.createDirectory(at: real, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
|
||||
let link = unique.appendingPathComponent("linkdir")
|
||||
try fm.createSymbolicLink(at: link, withDestinationURL: real)
|
||||
let sockPath = link.appendingPathComponent("reyna.sock").path
|
||||
let exe = try hostExecutableURL()
|
||||
let proc = Process()
|
||||
proc.executableURL = exe
|
||||
proc.arguments = ["--socket", sockPath]
|
||||
proc.standardError = Pipe()
|
||||
proc.standardOutput = Pipe()
|
||||
try proc.run()
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while proc.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
XCTFail("Host should reject symlink parent and exit")
|
||||
} else {
|
||||
XCTAssertNotEqual(proc.terminationStatus, 0, "Should exit non-zero when parent is symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketParentRejectsWorldWritable() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
// Make parent world-writable 0777 but owned by us – should be rejected
|
||||
chmod(unique.path, 0o777)
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let exe = try hostExecutableURL()
|
||||
let proc = Process()
|
||||
proc.executableURL = exe
|
||||
proc.arguments = ["--socket", sockPath]
|
||||
proc.standardError = Pipe()
|
||||
proc.standardOutput = Pipe()
|
||||
try proc.run()
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while proc.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
XCTFail("Host should reject world-writable dedicated parent")
|
||||
} else {
|
||||
XCTAssertNotEqual(proc.terminationStatus, 0, "Should exit non-zero for world-writable parent")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 4) recv timeout / slow client
|
||||
|
||||
func testSlowClientDoesNotBlockHealthClient() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
// Open slow client that connects and sends partial without newline and keeps open
|
||||
let slowFd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
XCTAssertTrue(slowFd >= 0)
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = sockPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
|
||||
dst.withMemoryRebound(to: CChar.self, capacity: 104) { p in strncpy(p, cStr, 103) }
|
||||
}
|
||||
}
|
||||
let len = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let cr = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in connect(slowFd, saddr, len) }
|
||||
}
|
||||
XCTAssertEqual(cr, 0, "slow client connect should succeed")
|
||||
// Send incomplete data (no newline)
|
||||
let partial = "{\"id\":\"slow\",\"operation\":\"service.health\""
|
||||
_ = partial.withCString { cStr in send(slowFd, cStr, strlen(cStr), 0) }
|
||||
|
||||
// Give server a moment to be blocked in recv if vulnerable
|
||||
usleep(300_000)
|
||||
|
||||
// Now try health client – should succeed within timeout + small margin, not blocked forever.
|
||||
// Server should have a recv timeout ~5s, so this health client should succeed in < (timeout+2)s
|
||||
let start = Date()
|
||||
let req = #"{"id":"fast","operation":"service.health","arguments":{}}"#
|
||||
var gotResponse = false
|
||||
var lastError: Error? = nil
|
||||
for _ in 0..<3 {
|
||||
do {
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req, timeout: 6)
|
||||
if let data = respLine.data(using: .utf8),
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
obj["id"] as? String == "fast",
|
||||
obj["ok"] as? Bool == true {
|
||||
gotResponse = true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
lastError = error
|
||||
usleep(200_000)
|
||||
}
|
||||
}
|
||||
let elapsed = Date().timeIntervalSince(start)
|
||||
close(slowFd)
|
||||
|
||||
XCTAssertTrue(gotResponse, "Fast client should succeed despite slow client; lastError: \(String(describing: lastError)) elapsed: \(elapsed)s")
|
||||
XCTAssertLessThan(elapsed, 8, "Slow client should not block health client beyond timeout; elapsed \(elapsed)s")
|
||||
}
|
||||
|
||||
// MARK: - 5) oversized-line boundary
|
||||
|
||||
func testOversizedLineWithNewlineInSameChunk() throws {
|
||||
// This tests the bug where buf+chunk > limit and newline in most recent chunk is ignored.
|
||||
// Build a valid JSON line exactly 500 bytes, then newline, then extra garbage in same TCP chunk.
|
||||
// The server must accept the first line (<=64KiB) even if same recv includes bytes after newline.
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
XCTAssertTrue(fd >= 0)
|
||||
defer { close(fd) }
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = sockPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
|
||||
dst.withMemoryRebound(to: CChar.self, capacity: 104) { p in strncpy(p, cStr, 103) }
|
||||
}
|
||||
}
|
||||
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let cr = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in connect(fd, saddr, addrLen) }
|
||||
}
|
||||
XCTAssertEqual(cr, 0)
|
||||
|
||||
// Build payload: first line is valid health request (<64KiB) + "\n" + extra bytes that would make total > limit if counted, but second line invalid.
|
||||
// Actually to trigger bug, we need first line <=64KiB, but the chunk that contains newline also contains extra bytes making total > limit? The bug checks buf.count+n > limit before looking at newline in new chunk.
|
||||
// So simulate by sending one large send that is exactly 64KiB + extra.
|
||||
// We'll send a health request (~50 bytes) + "\n" + 70KiB of 'X's in ONE send call. The server reads up to 4096 at a time, but could still get newline in first recv.
|
||||
// Better: send health request + "\n" + large extra, and ensure server still returns ok for first line, not payload_too_large.
|
||||
let healthReq = #"{"id":"line-ok","operation":"service.health","arguments":{}}"#
|
||||
let extra = String(repeating: "X", count: 70*1024)
|
||||
let combined = healthReq + "\n" + extra
|
||||
guard let data = combined.data(using: .utf8) else { XCTFail("encode fail"); return }
|
||||
// Ignore SIGPIPE in this process to avoid signal 13 when server closes early
|
||||
signal(SIGPIPE, SIG_IGN)
|
||||
var sent = 0
|
||||
while sent < data.count {
|
||||
let n = data.withUnsafeBytes { ptr in send(fd, ptr.baseAddress!.advanced(by: sent), data.count - sent, 0) }
|
||||
if n <= 0 {
|
||||
if errno == EPIPE || errno == ECONNRESET { break }
|
||||
break
|
||||
}
|
||||
sent += n
|
||||
}
|
||||
|
||||
var responseData = Data()
|
||||
var buf = [UInt8](repeating: 0, count: 4096)
|
||||
let start = Date()
|
||||
while true {
|
||||
if Date().timeIntervalSince(start) > 3 { break }
|
||||
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let pr = poll(&pfd, 1, 200)
|
||||
if pr <= 0 { continue }
|
||||
let r = recv(fd, &buf, buf.count, 0)
|
||||
if r <= 0 { break }
|
||||
responseData.append(contentsOf: buf[0..<r])
|
||||
if let s = String(data: responseData, encoding: .utf8), s.contains("\n") { break }
|
||||
}
|
||||
guard let respStr = String(data: responseData, encoding: .utf8) else {
|
||||
XCTFail("No utf8 response")
|
||||
return
|
||||
}
|
||||
let firstLine = respStr.split(separator: "\n").first.map { String($0) } ?? respStr
|
||||
guard let d = firstLine.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else {
|
||||
XCTFail("Response not JSON: \(firstLine)")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(obj["id"] as? String, "line-ok", "Should preserve id of first line")
|
||||
XCTAssertEqual(obj["ok"] as? Bool, true, "First line <=64KiB should be accepted even when same chunk has extra bytes after newline, got: \(obj)")
|
||||
}
|
||||
|
||||
func testOversizedFirstLineStillRejected() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
let largeString = String(repeating: "A", count: 70*1024)
|
||||
let req = #"{"id":"big","operation":"service.health","arguments":{},"data":"\#(largeString)"}"#
|
||||
XCTAssertTrue(req.utf8.count > 65536)
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
|
||||
guard let data = respLine.data(using: .utf8),
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
XCTFail("Not JSON: \(respLine)"); return
|
||||
}
|
||||
XCTAssertEqual(obj["ok"] as? Bool, false)
|
||||
let code = (obj["error"] as? [String: Any])?["code"] as? String ?? ""
|
||||
XCTAssertTrue(code.contains("too_large") || code.contains("payload") || code.contains("invalid"), "Expected too_large code, got \(code)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
import Darwin
|
||||
@testable import ReynaCLIHostCore
|
||||
|
||||
final class SocketPathValidationTests: XCTestCase {
|
||||
|
||||
// Helpers to build fake LStatInfo
|
||||
private func dir(uid: uid_t, mode: mode_t, symlink: Bool = false) -> LStatInfo {
|
||||
return LStatInfo(uid: uid, mode: mode_t(mode), isSymlink: symlink, isDir: !symlink, exists: true)
|
||||
}
|
||||
|
||||
private func currentUID() -> uid_t { getuid() }
|
||||
|
||||
// Real default socket path: $HOME/Library/Application Support/reyna-cli/privacy/reyna-cli.sock
|
||||
// Actual Mac modes from bug report:
|
||||
// home /Users/adolforeyna = 0750, ~/Library = 0700, ~/Library/Application Support = 0700, privacy = 0700
|
||||
// Tier 2 allows 0750 for intermediates, tier 3 requires 0700 for dedicated runtime parent.
|
||||
func testRealDefaultHomeSocketPathValidationAccepts() throws {
|
||||
let uid = currentUID()
|
||||
let home = NSHomeDirectory() // /Users/adolforeyna
|
||||
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
|
||||
var map: [String: LStatInfo] = [:]
|
||||
map["/"] = dir(uid: 0, mode: 0o40755)
|
||||
map["/Users"] = dir(uid: 0, mode: 0o40755)
|
||||
|
||||
// helper to insert chain
|
||||
func insertChain(upTo target: String, defaultMode: mode_t, overrides: [String: mode_t] = [:]) {
|
||||
let url = URL(fileURLWithPath: target)
|
||||
var cur = ""
|
||||
for comp in url.pathComponents {
|
||||
if comp == "/" { cur = "/"; continue }
|
||||
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
|
||||
if map[cur] != nil { continue }
|
||||
if cur == "/" || cur == "/Users" { continue }
|
||||
let m = overrides[cur] ?? defaultMode
|
||||
map[cur] = dir(uid: uid, mode: m)
|
||||
}
|
||||
}
|
||||
|
||||
// Home itself 0750 per actual system
|
||||
map[home] = dir(uid: uid, mode: 0o40750)
|
||||
// Library and subdirs 0700 except home already set
|
||||
// Build full parent chain to privacy
|
||||
let parentOfSocket = URL(fileURLWithPath: socketPath).deletingLastPathComponent().path
|
||||
// For parent chain: home is 0750 override, others 0700
|
||||
var chainCur = ""
|
||||
for comp in URL(fileURLWithPath: parentOfSocket).pathComponents {
|
||||
if comp == "/" { chainCur = "/"; continue }
|
||||
if chainCur == "/" { chainCur = "/" + comp } else if chainCur.isEmpty { chainCur = comp } else { chainCur = chainCur + "/" + comp }
|
||||
if map[chainCur] != nil { continue }
|
||||
if chainCur == "/" || chainCur == "/Users" { continue }
|
||||
if chainCur == home { continue } // already 0750
|
||||
map[chainCur] = dir(uid: uid, mode: 0o40700)
|
||||
}
|
||||
|
||||
let provider: LStatProvider = { path in map[path] }
|
||||
|
||||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||||
"Real default home with 0750 home and 0700 Library/.../privacy must validate")
|
||||
|
||||
XCTAssertTrue(platformTrustedRootPaths.contains("/Users"))
|
||||
XCTAssertTrue(platformTrustedRootPaths.contains("/"))
|
||||
}
|
||||
|
||||
func testRealDefaultHomeSocketPathValidationAcceptsWithHome0755() throws {
|
||||
// Also allow 0755 for home (some configs) – tier 2 should still allow as no write
|
||||
let uid = currentUID()
|
||||
let home = NSHomeDirectory()
|
||||
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: 0, mode: 0o40755)
|
||||
]
|
||||
map[home] = dir(uid: uid, mode: 0o40755)
|
||||
let parentPath = URL(fileURLWithPath: socketPath).deletingLastPathComponent().path
|
||||
var cur = ""
|
||||
for comp in URL(fileURLWithPath: parentPath).pathComponents {
|
||||
if comp == "/" { cur = "/"; continue }
|
||||
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
|
||||
if map[cur] != nil { continue }
|
||||
if cur == "/" || cur == "/Users" { continue }
|
||||
map[cur] = dir(uid: uid, mode: 0o40700)
|
||||
}
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||||
"Home 0755 should also be allowed (no write)")
|
||||
}
|
||||
|
||||
// Safe vs unsafe ancestor decision with fake stats
|
||||
func testTrustedRootMustBeRootOwned() {
|
||||
let uid = currentUID()
|
||||
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: uid, mode: 0o40755), // wrong: owned by current user, should fail - /Users must be uid 0
|
||||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40700)
|
||||
]
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider)) { err in
|
||||
let msg = (err as NSError).localizedDescription
|
||||
XCTAssertTrue(msg.contains("/Users") || msg.contains("current uid") || msg.contains("not owned"))
|
||||
}
|
||||
}
|
||||
|
||||
func testTrustedRootMustNotBeWorldWritable() {
|
||||
let uid = currentUID()
|
||||
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: 0, mode: 0o40777), // world writable unsafe
|
||||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40700)
|
||||
]
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider)) { err in
|
||||
XCTAssertTrue((err as NSError).code == 25 || (err as NSError).localizedDescription.contains("permissions"))
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsArbitraryRootOwnedIntermediatePath() {
|
||||
// E.g. /tmp/root_owned_dir owned by root should be REJECTED because not in allowlist
|
||||
let uid = currentUID()
|
||||
let socketPath = "/tmp/root_owned_dir/reyna.sock"
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
// /tmp is symlink-allowed platform path
|
||||
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true), // symlink allowed
|
||||
"/tmp/root_owned_dir": dir(uid: 0, mode: 0o40700) // root owned but not allowlisted
|
||||
]
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||||
"Arbitrary root-owned path /tmp/root_owned_dir must be rejected – only explicit allowlist trusted")
|
||||
}
|
||||
|
||||
func testRejectsHomeNotOwnedByCurrentUID() {
|
||||
let uid = currentUID()
|
||||
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: 0, mode: 0o40755),
|
||||
"/Users/\(NSUserName())": dir(uid: 0, mode: 0o40700) // wrong owner
|
||||
]
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
|
||||
}
|
||||
|
||||
func testAllowsHome0750ForIntermediateButRejectsWritable() {
|
||||
// Tier 2: intermediate ancestors allow 0750/0755, reject writable bits
|
||||
let uid = currentUID()
|
||||
let socketPath = "/Users/\(NSUserName())/Library/Application Support/reyna-cli/privacy/reyna.sock"
|
||||
// 0750 for home should be accepted (intermediate)
|
||||
var mapAllow: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: 0, mode: 0o40755),
|
||||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40750),
|
||||
"/Users/\(NSUserName())/Library": dir(uid: uid, mode: 0o40700),
|
||||
"/Users/\(NSUserName())/Library/Application Support": dir(uid: uid, mode: 0o40700),
|
||||
"/Users/\(NSUserName())/Library/Application Support/reyna-cli": dir(uid: uid, mode: 0o40700),
|
||||
"/Users/\(NSUserName())/Library/Application Support/reyna-cli/privacy": dir(uid: uid, mode: 0o40700)
|
||||
]
|
||||
let providerAllow: LStatProvider = { mapAllow[$0] }
|
||||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: providerAllow),
|
||||
"HOME 0750 as intermediate must be allowed (no write bits)")
|
||||
|
||||
// 0770 (group writable) for home must be rejected
|
||||
var mapReject: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: 0, mode: 0o40755),
|
||||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40770)
|
||||
]
|
||||
let socketPath2 = "/Users/\(NSUserName())/Library/reyna.sock"
|
||||
let providerReject: LStatProvider = { mapReject[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath2, currentUID: uid, provider: providerReject),
|
||||
"HOME 0770 (group writable) must be rejected even for intermediate")
|
||||
|
||||
// 0777 world writable must be rejected
|
||||
var mapReject2: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: 0, mode: 0o40755),
|
||||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40777)
|
||||
]
|
||||
let providerReject2: LStatProvider = { mapReject2[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath2, currentUID: uid, provider: providerReject2),
|
||||
"HOME 0777 must be rejected")
|
||||
}
|
||||
|
||||
func testRejectsDedicatedRuntimeParentWith0750or0755() {
|
||||
let uid = currentUID()
|
||||
let home = NSHomeDirectory()
|
||||
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
|
||||
// privacy dir 0750 must be rejected (tier 3 requires 0700)
|
||||
var map0750: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: 0, mode: 0o40755),
|
||||
]
|
||||
// build chain but make privacy 0750
|
||||
var cur = ""
|
||||
for comp in URL(fileURLWithPath: URL(fileURLWithPath: socketPath).deletingLastPathComponent().path).pathComponents {
|
||||
if comp == "/" { cur = "/"; continue }
|
||||
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
|
||||
if map0750[cur] != nil { continue }
|
||||
if cur == "/" || cur == "/Users" { continue }
|
||||
if cur.hasSuffix("/privacy") {
|
||||
map0750[cur] = dir(uid: uid, mode: 0o40750)
|
||||
} else if cur == home {
|
||||
map0750[cur] = dir(uid: uid, mode: 0o40750) // home 0750 allowed
|
||||
} else {
|
||||
map0750[cur] = dir(uid: uid, mode: 0o40700)
|
||||
}
|
||||
}
|
||||
let provider0750: LStatProvider = { map0750[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider0750),
|
||||
"Dedicated runtime parent privacy with 0750 must be rejected – requires 0700")
|
||||
|
||||
// 0755 also rejected
|
||||
var map0755 = map0750
|
||||
let privacyPath = URL(fileURLWithPath: socketPath).deletingLastPathComponent().path
|
||||
map0755[privacyPath] = dir(uid: uid, mode: 0o40755)
|
||||
let provider0755: LStatProvider = { map0755[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider0755),
|
||||
"Dedicated runtime parent privacy with 0755 must be rejected")
|
||||
}
|
||||
|
||||
func testAllowsIntermediate0755ButRequiresPrivacy0700() {
|
||||
let uid = currentUID()
|
||||
let home = NSHomeDirectory()
|
||||
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: 0, mode: 0o40755),
|
||||
]
|
||||
var cur = ""
|
||||
for comp in URL(fileURLWithPath: URL(fileURLWithPath: socketPath).deletingLastPathComponent().path).pathComponents {
|
||||
if comp == "/" { cur = "/"; continue }
|
||||
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
|
||||
if map[cur] != nil { continue }
|
||||
if cur == "/" || cur == "/Users" { continue }
|
||||
if cur == home {
|
||||
map[cur] = dir(uid: uid, mode: 0o40755) // home 0755 allowed as intermediate
|
||||
} else if cur.hasSuffix("/privacy") == false {
|
||||
// intermediate Library etc can be 0750/0755
|
||||
map[cur] = dir(uid: uid, mode: 0o40750)
|
||||
} else {
|
||||
map[cur] = dir(uid: uid, mode: 0o40700) // privacy must be 0700
|
||||
}
|
||||
}
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||||
"Intermediate 0750/0755 allowed, privacy 0700 must validate")
|
||||
}
|
||||
|
||||
func testRejectsSymlinkInUserChain() {
|
||||
let uid = currentUID()
|
||||
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: 0, mode: 0o40755),
|
||||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40700),
|
||||
"/Users/\(NSUserName())/Library": dir(uid: uid, mode: 0o120777, symlink: true)
|
||||
]
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||||
"Symlink in user chain must be rejected")
|
||||
}
|
||||
|
||||
func testAllowsPlatformSymlinksTmpVar() {
|
||||
let uid = currentUID()
|
||||
let socketPath = "/tmp/rh-test/reyna.sock"
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/private": dir(uid: 0, mode: 0o40755),
|
||||
"/private/tmp": dir(uid: 0, mode: 0o41777), // /private/tmp typically 1777
|
||||
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true), // allowed symlink
|
||||
"/tmp/rh-test": dir(uid: uid, mode: 0o40700)
|
||||
]
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
|
||||
}
|
||||
|
||||
func testRejectsDotDot() {
|
||||
let uid = currentUID()
|
||||
let socketPath = "/tmp/rh/../evil.sock"
|
||||
var map: [String: LStatInfo] = [:]
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
|
||||
}
|
||||
|
||||
func testNoBroadGlobalShortcutOnlyAllowlist() {
|
||||
// Ensure implementation does not accept every root-owned path.
|
||||
// For path /opt/rootdir where /opt is root-owned (simulating arbitrary root path), it must be rejected unless explicitly allowlisted.
|
||||
let uid = currentUID()
|
||||
let socketPath = "/opt/rootdir/reyna.sock"
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/opt": dir(uid: 0, mode: 0o40755), // root owned, not allowlisted
|
||||
"/opt/rootdir": dir(uid: uid, mode: 0o40700)
|
||||
]
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||||
"Must not accept arbitrary root-owned /opt")
|
||||
}
|
||||
|
||||
func testAllowsPrivateVarChain() {
|
||||
// macOS: /var -> /private/var, /private/var/tmp etc are platform trusted
|
||||
let uid = currentUID()
|
||||
let socketPath = "/private/var/tmp/rh-test/reyna.sock"
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/private": dir(uid: 0, mode: 0o40755),
|
||||
"/private/var": dir(uid: 0, mode: 0o40755),
|
||||
"/private/var/tmp": dir(uid: 0, mode: 0o41777),
|
||||
"/private/var/tmp/rh-test": dir(uid: uid, mode: 0o40700)
|
||||
]
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
|
||||
}
|
||||
|
||||
func testRejectsGroupWritableIntermediate() {
|
||||
let uid = currentUID()
|
||||
let socketPath = "/Users/\(NSUserName())/Library/Application Support/reyna.sock"
|
||||
var map: [String: LStatInfo] = [
|
||||
"/": dir(uid: 0, mode: 0o40755),
|
||||
"/Users": dir(uid: 0, mode: 0o40755),
|
||||
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40755),
|
||||
"/Users/\(NSUserName())/Library": dir(uid: uid, mode: 0o40770),
|
||||
"/Users/\(NSUserName())/Library/Application Support": dir(uid: uid, mode: 0o40700)
|
||||
]
|
||||
let provider: LStatProvider = { map[$0] }
|
||||
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
|
||||
"Intermediate Library 0770 group writable must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
|
||||
/// TDD tests for Unix-domain-socket server mode (--socket <path>).
|
||||
/// These start the compiled executable in a temp directory using real AF_UNIX sockets.
|
||||
final class SocketServerTests: XCTestCase {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func hostExecutableURL() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
// Portable candidates only: package-relative .build locations for common triples.
|
||||
let candidates: [String] = [
|
||||
".build/debug/ReynaCLIHost",
|
||||
".build/arm64-apple-macosx/debug/ReynaCLIHost",
|
||||
".build/x86_64-apple-macosx/debug/ReynaCLIHost",
|
||||
]
|
||||
let cwd = fm.currentDirectoryPath
|
||||
var tried: [String] = []
|
||||
for c in candidates {
|
||||
let url = URL(fileURLWithPath: cwd).appendingPathComponent(c)
|
||||
tried.append(url.path)
|
||||
if fm.isExecutableFile(atPath: url.path) { return url }
|
||||
}
|
||||
var parent = URL(fileURLWithPath: cwd)
|
||||
for _ in 0..<6 {
|
||||
let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost")
|
||||
tried.append(p1.path)
|
||||
if fm.isExecutableFile(atPath: p1.path) { return p1 }
|
||||
let p2 = parent.appendingPathComponent(".build/arm64-apple-macosx/debug/ReynaCLIHost")
|
||||
tried.append(p2.path)
|
||||
if fm.isExecutableFile(atPath: p2.path) { return p2 }
|
||||
parent = parent.deletingLastPathComponent()
|
||||
}
|
||||
throw NSError(domain: "SocketServerTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "ReynaCLIHost binary not found. Tried:\n" + tried.joined(separator: "\n")])
|
||||
}
|
||||
|
||||
/// Short unique /tmp path to stay under sockaddr_un.sun_path 104-byte limit.
|
||||
/// e.g. /tmp/rh-a1b2c3d4
|
||||
func makeShortUniqueDirChecked() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
for _ in 0..<20 {
|
||||
let hex = String(format: "%08x", UInt32.random(in: 0...UInt32.max))
|
||||
let url = URL(fileURLWithPath: "/tmp/rh-\(hex)")
|
||||
if !fm.fileExists(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return URL(fileURLWithPath: "/tmp/rh-\(String(format: "%08x", UInt32.random(in: 0...UInt32.max)))")
|
||||
}
|
||||
|
||||
final class HostProcess {
|
||||
let process: Process
|
||||
let socketPath: String
|
||||
let tempDir: URL
|
||||
init(process: Process, socketPath: String, tempDir: URL) {
|
||||
self.process = process
|
||||
self.socketPath = socketPath
|
||||
self.tempDir = tempDir
|
||||
}
|
||||
func terminate() {
|
||||
if process.isRunning { process.terminate() }
|
||||
// Give time to cleanup
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while process.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if process.isRunning { process.interrupt() }
|
||||
}
|
||||
deinit { terminate() }
|
||||
}
|
||||
|
||||
func startSocketHost(socketPath: String, tempDir: URL) throws -> HostProcess {
|
||||
let exe = try hostExecutableURL()
|
||||
let process = Process()
|
||||
process.executableURL = exe
|
||||
process.arguments = ["--socket", socketPath]
|
||||
let stderr = Pipe()
|
||||
process.standardError = stderr
|
||||
process.standardOutput = Pipe()
|
||||
process.standardInput = Pipe() // keep open, not used
|
||||
try process.run()
|
||||
// Wait for socket to appear (max 5s)
|
||||
let fm = FileManager.default
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while Date() < deadline {
|
||||
if fm.fileExists(atPath: socketPath) { break }
|
||||
if !process.isRunning {
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let s = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "SocketServerTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host exited early. stderr: \(s)"])
|
||||
}
|
||||
// Do not call FileHandle.availableData here: it blocks while a healthy,
|
||||
// silent child keeps stderr open. Poll the socket path instead.
|
||||
usleep(100_000)
|
||||
}
|
||||
if !fm.fileExists(atPath: socketPath) {
|
||||
process.terminate()
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let s = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "SocketServerTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Socket not created at \(socketPath) after timeout. stderr: \(s)"])
|
||||
}
|
||||
return HostProcess(process: process, socketPath: socketPath, tempDir: tempDir)
|
||||
}
|
||||
|
||||
// Low-level socket client: connect, send line, read one line response with timeout
|
||||
func socketRequestResponse(socketPath: String, requestLine: String, timeout: TimeInterval = 3) throws -> String {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw NSError(domain: "SocketServerTests", code: 10, userInfo: [NSLocalizedDescriptionKey: "socket() failed: \(String(cString: strerror(errno)))"]) }
|
||||
defer { close(fd) }
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let pathBytes = socketPath.utf8
|
||||
guard pathBytes.count < MemoryLayout.size(ofValue: addr.sun_path) else {
|
||||
throw NSError(domain: "SocketServerTests", code: 11, userInfo: [NSLocalizedDescriptionKey: "Socket path too long"])
|
||||
}
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = socketPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dstPtr in
|
||||
dstPtr.withMemoryRebound(to: CChar.self, capacity: 104) { charPtr in
|
||||
strncpy(charPtr, cStr, 103)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let connectResult = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
|
||||
connect(fd, saddr, addrLen)
|
||||
}
|
||||
}
|
||||
guard connectResult == 0 else {
|
||||
throw NSError(domain: "SocketServerTests", code: 12, userInfo: [NSLocalizedDescriptionKey: "connect() failed to \(socketPath): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
|
||||
let toSend = requestLine.hasSuffix("\n") ? requestLine : requestLine + "\n"
|
||||
guard let data = toSend.data(using: .utf8) else { throw NSError(domain: "SocketServerTests", code: 13, userInfo: [NSLocalizedDescriptionKey: "UTF8 encode fail"]) }
|
||||
var sent = 0
|
||||
while sent < data.count {
|
||||
let n = data.withUnsafeBytes { rawBuf in
|
||||
send(fd, rawBuf.baseAddress!.advanced(by: sent), data.count - sent, 0)
|
||||
}
|
||||
if n <= 0 { throw NSError(domain: "SocketServerTests", code: 14, userInfo: [NSLocalizedDescriptionKey: "send() failed: \(String(cString: strerror(errno)))"]) }
|
||||
sent += n
|
||||
}
|
||||
|
||||
var responseData = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
let start = Date()
|
||||
while true {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
let partial = String(data: responseData, encoding: .utf8) ?? "<binary>"
|
||||
throw NSError(domain: "SocketServerTests", code: 15, userInfo: [NSLocalizedDescriptionKey: "socket read timeout, partial: \(partial)"])
|
||||
}
|
||||
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let pr = poll(&pfd, 1, 200) // 200ms
|
||||
if pr < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw NSError(domain: "SocketServerTests", code: 16, userInfo: [NSLocalizedDescriptionKey: "poll failed: \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
if pr == 0 { continue }
|
||||
let r = recv(fd, &buffer, buffer.count, 0)
|
||||
if r < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw NSError(domain: "SocketServerTests", code: 17, userInfo: [NSLocalizedDescriptionKey: "recv failed: \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
if r == 0 { break }
|
||||
responseData.append(contentsOf: buffer[0..<r])
|
||||
if let str = String(data: responseData, encoding: .utf8), str.contains("\n") {
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let respString = String(data: responseData, encoding: .utf8) else {
|
||||
throw NSError(domain: "SocketServerTests", code: 18, userInfo: [NSLocalizedDescriptionKey: "response not utf8"])
|
||||
}
|
||||
let firstLine = respString.split(separator: "\n", omittingEmptySubsequences: false).first.map { String($0) } ?? respString
|
||||
return firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func decode(_ line: String) throws -> [String: Any] {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw NSError(domain: "SocketServerTests", code: 20, userInfo: [NSLocalizedDescriptionKey: "Not JSON: \(line)"])
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// MARK: - Tests (TDD RED first)
|
||||
|
||||
func testSocketHealthRequest() throws {
|
||||
// Prove that --socket mode health request works via real Unix socket.
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate(); try? fm.removeItem(atPath: sockPath) }
|
||||
|
||||
let req = #"{"id":"sock-1","operation":"service.health","arguments":{}}"#
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
|
||||
let resp = try decode(respLine)
|
||||
XCTAssertEqual(resp["id"] as? String, "sock-1")
|
||||
XCTAssertEqual(resp["ok"] as? Bool, true)
|
||||
if let result = resp["result"] as? [String: Any] {
|
||||
XCTAssertEqual(result["operation"] as? String, "service.health")
|
||||
XCTAssertFalse((result["protocol_version"] as? String ?? "").isEmpty)
|
||||
} else {
|
||||
XCTFail("Missing result: \(resp)")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketPermissionsAndParentCreation() throws {
|
||||
// Prove socket file mode 0600 and parent dir 0700, and auto-create parent.
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
// Do NOT create unique; let child subdir also not exist, testing parent creation
|
||||
let nestedParent = unique.appendingPathComponent("a/b/c")
|
||||
let sockPath = nestedParent.appendingPathComponent("reyna.sock").path
|
||||
// Ensure base exists for cleanup tracking but not nested
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
XCTAssertTrue(fm.fileExists(atPath: nestedParent.path, isDirectory: &isDir))
|
||||
XCTAssertTrue(isDir.boolValue)
|
||||
|
||||
let attrs = try fm.attributesOfItem(atPath: nestedParent.path)
|
||||
if let posix = attrs[.posixPermissions] as? NSNumber {
|
||||
let perms = posix.uint16Value & 0o777
|
||||
XCTAssertEqual(perms, 0o700, "Parent dir should be 0700, got \(String(perms, radix: 8))")
|
||||
} else {
|
||||
XCTFail("Could not get posixPermissions for parent")
|
||||
}
|
||||
|
||||
// Check socket file mode 0600 and type socket
|
||||
let sockAttrs = try fm.attributesOfItem(atPath: sockPath)
|
||||
if let posix = sockAttrs[.posixPermissions] as? NSNumber {
|
||||
let perms = posix.uint16Value & 0o777
|
||||
XCTAssertEqual(perms, 0o600, "Socket file should be 0600, got \(String(perms, radix: 8))")
|
||||
} else {
|
||||
XCTFail("Could not get posixPermissions for socket")
|
||||
}
|
||||
// Verify it's a socket using lstat mode check
|
||||
var st = stat()
|
||||
XCTAssertEqual(lstat(sockPath, &st), 0, "lstat should succeed")
|
||||
XCTAssertTrue((st.st_mode & S_IFMT) == S_IFSOCK, "File should be a socket")
|
||||
|
||||
// Also ensure owned by current uid
|
||||
XCTAssertEqual(st.st_uid, getuid(), "Socket should be owned by current uid")
|
||||
}
|
||||
|
||||
func testSocketMalformedRequest() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
// Send malformed JSON
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: "not json at all")
|
||||
let resp = try decode(respLine)
|
||||
XCTAssertEqual(resp["ok"] as? Bool, false)
|
||||
if let err = resp["error"] as? [String: Any] {
|
||||
XCTAssertEqual(err["code"] as? String, "invalid_request")
|
||||
} else {
|
||||
XCTFail("Missing error, got \(resp)")
|
||||
}
|
||||
// id should be empty when unrecoverable
|
||||
XCTAssertEqual(resp["id"] as? String, "")
|
||||
|
||||
// Send malformed but with id field to test preservation
|
||||
let respLine2 = try socketRequestResponse(socketPath: sockPath, requestLine: #"{"id":"keep-me","operation":}"#)
|
||||
let resp2 = try decode(respLine2)
|
||||
XCTAssertEqual(resp2["ok"] as? Bool, false)
|
||||
XCTAssertEqual(resp2["id"] as? String, "keep-me")
|
||||
if let err = resp2["error"] as? [String: Any] {
|
||||
XCTAssertEqual(err["code"] as? String, "invalid_request")
|
||||
} else {
|
||||
XCTFail("Missing error for second malformed")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketOversizedRequestBeyond64KiB() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
// Create payload > 64KiB
|
||||
let largeString = String(repeating: "A", count: 70*1024)
|
||||
let req = #"{"id":"big","operation":"service.health","arguments":{},"data":"\#(largeString)"}"#
|
||||
// Must be > 65536 bytes
|
||||
XCTAssertTrue(req.utf8.count > 65536)
|
||||
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
|
||||
let resp = try decode(respLine)
|
||||
XCTAssertEqual(resp["ok"] as? Bool, false, "Oversized should be rejected")
|
||||
if let err = resp["error"] as? [String: Any] {
|
||||
let code = err["code"] as? String ?? ""
|
||||
XCTAssertTrue(code == "invalid_request" || code == "payload_too_large" || code == "request_too_large" || code.contains("too_large") || code.contains("invalid"), "Unexpected error code for oversized: \(code)")
|
||||
} else {
|
||||
XCTFail("Missing error for oversized: \(resp)")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketCleanupOnTermination() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
var maybeHost: HostProcess? = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
XCTAssertTrue(fm.fileExists(atPath: sockPath), "Socket should exist while host running")
|
||||
maybeHost?.terminate()
|
||||
maybeHost = nil
|
||||
// Wait a bit for cleanup
|
||||
let deadline = Date().addingTimeInterval(3)
|
||||
while fm.fileExists(atPath: sockPath) && Date() < deadline { usleep(100_000) }
|
||||
XCTAssertFalse(fm.fileExists(atPath: sockPath), "Socket file should be removed on SIGTERM cleanup")
|
||||
}
|
||||
|
||||
func testSocketRefusesNonSocketExistingFile() throws {
|
||||
// If path exists and is regular file owned by uid, should refuse (not unlink unsafe)
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
// Create regular file there
|
||||
fm.createFile(atPath: sockPath, contents: Data("hello".utf8))
|
||||
defer { try? fm.removeItem(atPath: sockPath) }
|
||||
|
||||
// Try start - should fail quickly (exit)
|
||||
let exe = try hostExecutableURL()
|
||||
let process = Process()
|
||||
process.executableURL = exe
|
||||
process.arguments = ["--socket", sockPath]
|
||||
let stderr = Pipe()
|
||||
process.standardError = stderr
|
||||
process.standardOutput = Pipe()
|
||||
try process.run()
|
||||
let deadline = Date().addingTimeInterval(3)
|
||||
while process.isRunning && Date() < deadline { usleep(100_000) }
|
||||
// Process should have exited with error, not be running and not have created socket replacing file
|
||||
var isSocket = false
|
||||
var st = stat()
|
||||
if lstat(sockPath, &st) == 0 {
|
||||
isSocket = (st.st_mode & S_IFMT) == S_IFSOCK
|
||||
}
|
||||
XCTAssertFalse(isSocket, "Should not have replaced regular file with socket")
|
||||
// If process still running, terminate and fail
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
XCTFail("Host should refuse to overwrite regular file and exit, but it is still running")
|
||||
} else {
|
||||
// Should exit non-zero
|
||||
XCTAssertNotEqual(process.terminationStatus, 0, "Should exit non-zero when refusing non-socket file")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ dependencies = [
|
||||
"python-multipart>=0.0.32",
|
||||
"pillow>=12.3.0",
|
||||
"rmscene>=0.8.0",
|
||||
"mlx-audio>=0.4.8",
|
||||
"pocket-tts==2.1.0",
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from threading import Thread, Event
|
||||
|
||||
SWIFT_SOURCE = """
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
|
||||
struct InMsg: Decodable {
|
||||
var id: String?
|
||||
var mode: String // "line" | "paragraph" | "check" | "quick_reply" | "chat"
|
||||
var text: String?
|
||||
var prev1: String?
|
||||
var prev2: String?
|
||||
var context: String?
|
||||
var prevSource: String?
|
||||
var language: String?
|
||||
var instructions: String?
|
||||
var history: String? // JSON array [{"role":"user","text":".."},...]
|
||||
}
|
||||
struct OutMsg: Encodable {
|
||||
var id: String?
|
||||
var ok: Bool
|
||||
var text: String
|
||||
var error: String?
|
||||
var ms: Int?
|
||||
var mode: String?
|
||||
}
|
||||
func log(_ s: String) { fputs(s+"\\n", stderr) }
|
||||
|
||||
@main
|
||||
struct AppleLLMPolish {
|
||||
static func main() async {
|
||||
let args = CommandLine.arguments
|
||||
if args.contains("--help") || args.contains("-h") {
|
||||
fputs("Usage: apple-llm-polish [--check]\\nPipe JSONL in stdin, JSONL out\\nModes: line, paragraph, quick_reply, chat, check\\n", stderr); exit(0)
|
||||
}
|
||||
if args.contains("--check") { await runCheck(); return }
|
||||
await runPipe()
|
||||
}
|
||||
static func runCheck() async {
|
||||
let m = SystemLanguageModel.default
|
||||
var pingText = "unavailable"
|
||||
var ok = false
|
||||
if m.isAvailable {
|
||||
do {
|
||||
let session = LanguageModelSession(model: m, instructions: "You are concise.")
|
||||
let r = try await session.respond(to: "Say ok")
|
||||
pingText = r.content
|
||||
ok = true
|
||||
} catch { pingText = error.localizedDescription }
|
||||
}
|
||||
let out: [String: Any] = [
|
||||
"available": m.isAvailable,
|
||||
"availability": "\\(m.availability)",
|
||||
"ping": pingText,
|
||||
"ok": ok,
|
||||
"model": "SystemLanguageModel 3B ANE"
|
||||
]
|
||||
if let d = try? JSONSerialization.data(withJSONObject: out), let s = String(data: d, encoding: .utf8) { print(s) }
|
||||
}
|
||||
static func runPipe() async {
|
||||
let m = SystemLanguageModel.default
|
||||
guard m.isAvailable else {
|
||||
let reason = "\\(m.availability)"
|
||||
while let line = readLine() {
|
||||
if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue }
|
||||
var idv: String? = nil
|
||||
if let data = line.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data) as? [String:Any] { idv = dict["id"] as? String }
|
||||
let out = OutMsg(id: idv, ok: false, text: "", error: "model unavailable: \\(reason)", ms: nil, mode: "error")
|
||||
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||
}
|
||||
return
|
||||
}
|
||||
let lineSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: lineSystemPrompt())
|
||||
let paraSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: paraSystemPrompt())
|
||||
let quickSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: quickReplySystemPrompt())
|
||||
let chatSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: "You are Hermes, a concise helpful voice assistant for ESP32 devices. Keep replies under 40 words, warm and concrete, kid-safe.")
|
||||
|
||||
lineSession.prewarm()
|
||||
paraSession.prewarm()
|
||||
quickSession.prewarm()
|
||||
chatSession.prewarm()
|
||||
|
||||
log("[apple-llm] ready, ANE-backed 3B")
|
||||
|
||||
while let line = readLine() {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { continue }
|
||||
guard let data = line.data(using: .utf8), let req = try? JSONDecoder().decode(InMsg.self, from: data) else {
|
||||
let out = OutMsg(id: nil, ok: false, text: "", error: "bad json", ms: nil, mode: "error")
|
||||
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||
continue
|
||||
}
|
||||
if req.mode == "check" {
|
||||
let out = OutMsg(id: req.id, ok: m.isAvailable, text: "\\(m.availability)", error: nil, ms: 0, mode: "check")
|
||||
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||
continue
|
||||
}
|
||||
let t0 = Date()
|
||||
do {
|
||||
let (session, prompt, temp): (LanguageModelSession, String, Double)
|
||||
switch req.mode {
|
||||
case "paragraph":
|
||||
session = paraSession
|
||||
prompt = buildParagraphPrompt(context: req.context ?? "", prevSource: req.prevSource ?? "", newText: req.text ?? "")
|
||||
temp = 0.2
|
||||
case "quick_reply":
|
||||
session = quickSession
|
||||
prompt = buildQuickReplyPrompt(draft: req.text ?? "", context: req.context, instructions: req.instructions)
|
||||
temp = 0.4
|
||||
case "chat":
|
||||
session = chatSession
|
||||
if let hist = req.history, !hist.isEmpty {
|
||||
prompt = buildChatPrompt(historyJSON: hist, newText: req.text ?? "", instructions: req.instructions)
|
||||
} else {
|
||||
prompt = req.text ?? ""
|
||||
}
|
||||
temp = 0.5
|
||||
default: // line
|
||||
session = lineSession
|
||||
prompt = buildLinePrompt(text: req.text ?? "", prev1: req.prev1 ?? "", prev2: req.prev2 ?? "")
|
||||
temp = 0.1
|
||||
}
|
||||
var opts = GenerationOptions()
|
||||
opts.temperature = temp
|
||||
let resp = try await session.respond(to: prompt, options: opts)
|
||||
let ms = Int(Date().timeIntervalSince(t0)*1000)
|
||||
let cleaned = resp.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let out = OutMsg(id: req.id, ok: true, text: cleaned.isEmpty ? (req.text ?? "") : cleaned, error: nil, ms: ms, mode: req.mode)
|
||||
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||
} catch {
|
||||
let ms = Int(Date().timeIntervalSince(t0)*1000)
|
||||
let out = OutMsg(id: req.id, ok: false, text: req.text ?? "", error: error.localizedDescription, ms: ms, mode: req.mode)
|
||||
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||
}
|
||||
}
|
||||
}
|
||||
static func lineSystemPrompt() -> String {
|
||||
return "You are a real-time caption polisher. Fix punctuation, casing, STT typos. Remove filler (uh, um). Keep meaning. Output one polished line only."
|
||||
}
|
||||
static func paraSystemPrompt() -> String {
|
||||
return "You are a careful live transcript editor. Goal: most faithful readable English. NEW SOURCE TEXT is primary. PREVIOUS CONTEXT only if helps continuity. English only. No meta commentary. Return revised transcript only."
|
||||
}
|
||||
static func quickReplySystemPrompt() -> String {
|
||||
return \"\"\"
|
||||
You are Hermes instant-reply for ESP32 voice devices (iPhone, Watch, kids). You get LIVE draft transcript from user, possibly partial with typos.
|
||||
|
||||
Goal: produce a super-short, HIGHLY CONTEXTUAL reply preview (max 20 words) that shows you actually understood their specific request, not generic.
|
||||
|
||||
Rules:
|
||||
|- Reference SPECIFIC keywords/entities from draft: names (Grace Priss/Rain), topics (Mac mini voice, weather, homework), intent.
|
||||
|- Sound human, warm, playful for kids, concise.
|
||||
|- If draft mentions Mac mini voice/boys voice/speech, acknowledge you'll use Mac mini voice.
|
||||
|- If draft mentions a name, use it.
|
||||
|- If draft asks something, hint at answer direction without fully answering (full answer comes next).
|
||||
|- Never say "Thinking on full answer" verbatim — too generic. Instead vary: "Let me check...", "One sec, pulling that...", "Nice name! Love it..."
|
||||
|- Under 20 words. Return ONLY reply text, no quotes.
|
||||
|
||||
Examples:
|
||||
Draft: "what's the weather today" -> "Checking weather now — one sec..."
|
||||
Draft: "Perfect. My first name is Grace Priss, and my other name is Grace Reign." -> "Wow, Grace Priss and Grace Reign — royal names! Love them!"
|
||||
Draft: "Why you're not answering with boys voice?" -> "Got it — you want boy voice, switching to Mac mini voice now..."
|
||||
Draft: "Can you use the Mac mini voice to generate answers?" -> "Yes! Using Mac mini voice for better audio, one sec..."
|
||||
Draft: "tell me a joke" -> "Joke coming up..."
|
||||
Draft: "Hey improvement I think now you should show quick response" -> "Nice! Quick response is live, working on full answer too..."
|
||||
\"\"\"
|
||||
}
|
||||
static func buildLinePrompt(text: String, prev1: String, prev2: String) -> String {
|
||||
var p = ""
|
||||
if !prev2.isEmpty { p += "Previous 2: \\(prev2)\\n" }
|
||||
if !prev1.isEmpty { p += "Previous 1: \\(prev1)\\n" }
|
||||
p += "Current: \\(text)\\nPolished:"
|
||||
return p
|
||||
}
|
||||
static func buildParagraphPrompt(context: String, prevSource: String, newText: String) -> String {
|
||||
return "Edit this transcript.\\n[PREVIOUS CONTEXT]\\n\\(context)\\n[PREVIOUS SOURCE]\\n\\(prevSource)\\n[NEW]\\n\\(newText)"
|
||||
}
|
||||
static func buildQuickReplyPrompt(draft: String, context: String?, instructions: String?) -> String {
|
||||
var p = "LIVE DRAFT from user speaking (may have typos, partial): \\"\\(draft)\\"\\n"
|
||||
if let c = context, !c.isEmpty { p += "Previous full transcript: \\(c)\\n" }
|
||||
if let i = instructions, !i.isEmpty { p += "Extra instructions: \\(i)\\n" }
|
||||
p += "\\nTask: produce contextual instant reply (max 20 words) that references SPECIFIC words from draft, not generic. If draft unclear, fall back to 'Heard you — working on full answer...'"
|
||||
return p
|
||||
}
|
||||
static func buildChatPrompt(historyJSON: String, newText: String, instructions: String?) -> String {
|
||||
var prompt = "Conversation history: \\(historyJSON)\\nUser says (draft/final): \\(newText)\\n"
|
||||
if let instructions, !instructions.isEmpty { prompt += "Instructions: \\(instructions)\\n" }
|
||||
return prompt + "Reply concisely for voice device (<40 words):"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
class AppleLLMClient:
|
||||
def __init__(self):
|
||||
self.proc: Optional[subprocess.Popen] = None
|
||||
self.tmp_dir: Optional[Path] = None
|
||||
self.bin_file: Optional[Path] = None
|
||||
self.pending: Dict[str, Tuple[Event, Dict[str, Any]]] = {}
|
||||
self.reader_thread: Optional[Thread] = None
|
||||
self.req_id = 0
|
||||
self.last_activity = time.time()
|
||||
|
||||
def _ensure_built(self):
|
||||
if self.bin_file and self.bin_file.exists():
|
||||
return
|
||||
|
||||
self.tmp_dir = Path(tempfile.mkdtemp(prefix="apple-llm-py-"))
|
||||
swift_file = self.tmp_dir / "Main.swift"
|
||||
self.bin_file = self.tmp_dir / "apple-llm-polish"
|
||||
|
||||
swift_file.write_text(SWIFT_SOURCE, encoding="utf-8")
|
||||
|
||||
try:
|
||||
subprocess.run([
|
||||
"/usr/bin/swiftc", "-O", "-parse-as-library", str(swift_file),
|
||||
"-o", str(self.bin_file), "-framework", "Foundation", "-framework", "FoundationModels"
|
||||
], check=True, timeout=60, capture_output=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"swiftc build failed: {e.stderr.decode()}") from e
|
||||
|
||||
def start(self):
|
||||
if self.proc and self.proc.poll() is None:
|
||||
return
|
||||
|
||||
self._ensure_built()
|
||||
|
||||
self.proc = subprocess.Popen(
|
||||
[str(self.bin_file)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
self.pending.clear()
|
||||
self.reader_thread = Thread(target=self._read_loop, daemon=True)
|
||||
self.reader_thread.start()
|
||||
|
||||
# Wait for the "ready" log or just a short timeout
|
||||
time.sleep(1.5)
|
||||
if self.proc.poll() is not None:
|
||||
raise RuntimeError(f"AppleLLM process exited early with code {self.proc.returncode}")
|
||||
|
||||
def _read_loop(self):
|
||||
try:
|
||||
while True:
|
||||
line = self.proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
req_id = obj.get("id")
|
||||
if req_id and req_id in self.pending:
|
||||
event, result_box = self.pending[req_id]
|
||||
result_box["data"] = obj
|
||||
event.set()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def call(self, payload: Dict[str, Any], timeout: float = 10.0) -> Dict[str, Any]:
|
||||
self.start()
|
||||
self.last_activity = time.time()
|
||||
|
||||
req_id = str(self.req_id)
|
||||
self.req_id += 1
|
||||
|
||||
full_payload = {"id": req_id, **payload}
|
||||
event = Event()
|
||||
result_box = {"data": None}
|
||||
self.pending[req_id] = (event, result_box)
|
||||
|
||||
try:
|
||||
self.proc.stdin.write(json.dumps(full_payload) + "\n")
|
||||
self.proc.stdin.flush()
|
||||
|
||||
if event.wait(timeout):
|
||||
res = result_box["data"]
|
||||
return res if res else {"ok": False, "error": "empty response"}
|
||||
else:
|
||||
return {"ok": False, "error": f"timeout {timeout}s", "id": req_id, **payload}
|
||||
finally:
|
||||
self.pending.pop(req_id, None)
|
||||
|
||||
def close(self):
|
||||
if self.proc:
|
||||
try:
|
||||
self.proc.stdin.close()
|
||||
self.proc.terminate()
|
||||
self.proc.wait(timeout=2)
|
||||
except Exception:
|
||||
self.proc.kill()
|
||||
if self.tmp_dir and self.tmp_dir.exists():
|
||||
shutil.rmtree(self.tmp_dir)
|
||||
self.proc = None
|
||||
self.bin_file = None
|
||||
|
||||
def check(self) -> Dict[str, Any]:
|
||||
# For check, we can use the --check flag as a separate run, or just call the pipe.
|
||||
# The Swift code supports --check for a one-off ping.
|
||||
self._ensure_built()
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[str(self.bin_file), "--check"],
|
||||
capture_output=True, text=True, timeout=10, check=True
|
||||
)
|
||||
return json.loads(res.stdout)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# Singleton for warm session
|
||||
_global_session: Optional[AppleLLMClient] = None
|
||||
|
||||
def get_apple_llm_session() -> AppleLLMClient:
|
||||
global _global_session
|
||||
if _global_session is None:
|
||||
_global_session = AppleLLMClient()
|
||||
return _global_session
|
||||
|
||||
def apple_llm_close():
|
||||
global _global_session
|
||||
if _global_session:
|
||||
_global_session.close()
|
||||
_global_session = None
|
||||
return {"ok": True, "closed": True}
|
||||
|
||||
def apple_llm_status():
|
||||
s = _global_session
|
||||
return {
|
||||
"active": s is not None,
|
||||
"ready": s is not None and s.proc is not None and s.proc.poll() is None,
|
||||
"last_activity": datetime.fromtimestamp(s.last_activity, timezone.utc).isoformat() if s else None,
|
||||
"pid": s.proc.pid if s and s.proc else None
|
||||
}
|
||||
+1088
-75
File diff suppressed because it is too large
Load Diff
+27
-3
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -36,6 +37,9 @@ class Device(BaseModel):
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
def resolved_host(self) -> str:
|
||||
return resolve_device_host(self)
|
||||
|
||||
def public_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
@@ -108,13 +112,12 @@ def load_registry(path: Optional[Path] = None) -> Registry:
|
||||
|
||||
def get_device(name: str, registry: Optional[Registry] = None) -> Optional[Device]:
|
||||
registry = registry or load_registry()
|
||||
normalized = name.strip()
|
||||
normalized = name.strip().casefold()
|
||||
shorthand = {
|
||||
"screen": "esp32_screen",
|
||||
"esp32": "esp32_screen",
|
||||
"iphone": "iphone_mcp",
|
||||
"phone": "iphone_mcp",
|
||||
"arm": "robot_arm",
|
||||
"laptop": "personal_laptop_screen",
|
||||
"laptop_screen": "personal_laptop_screen",
|
||||
"personal_laptop": "personal_laptop_screen",
|
||||
@@ -123,11 +126,32 @@ def get_device(name: str, registry: Optional[Registry] = None) -> Optional[Devic
|
||||
"local": "local_desktop_screen",
|
||||
}
|
||||
normalized = shorthand.get(normalized, normalized)
|
||||
aliases = {str(key).casefold(): value for key, value in registry.aliases.items()}
|
||||
normalized = aliases.get(normalized, normalized)
|
||||
for device in registry.devices:
|
||||
if normalized in {device.id, device.host, device.display_name}:
|
||||
candidates = {device.id.casefold(), device.host.casefold(), device.display_name.casefold()}
|
||||
if normalized in candidates:
|
||||
return device
|
||||
display = device.display_name.casefold()
|
||||
if display.startswith(normalized + " ") or display.startswith(normalized + "'s"):
|
||||
return device
|
||||
return None
|
||||
|
||||
|
||||
def resolve_device_host(device: Device) -> str:
|
||||
"""Resolve mDNS when available, retaining the registry IP for offline boards."""
|
||||
if device.host:
|
||||
try:
|
||||
return socket.gethostbyname(device.host)
|
||||
except OSError:
|
||||
pass
|
||||
return device.reserved_ip or device.host
|
||||
|
||||
|
||||
def is_tactility_device(device: Device) -> bool:
|
||||
value = f"{device.id} {device.type} {' '.join(device.capabilities)}".lower()
|
||||
return "tactility" in value or device.id.startswith("kidsos_") or device.id == "esp32_screen"
|
||||
|
||||
|
||||
def cache_path_for(device_id: str) -> Path:
|
||||
return CACHE_DIR / "devices" / f"{device_id}-tools.json"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from reyna_cli.env import load_hermes_env
|
||||
|
||||
DEFAULT_GITEA_URL = "https://git.reynafamily.com"
|
||||
SENSITIVE_KEYS = {"token", "access_token", "password", "authorization", "refresh_token"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GiteaCredentials:
|
||||
username: str
|
||||
token: str
|
||||
source: str
|
||||
base_url: str
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
parsed = urlsplit(self.base_url)
|
||||
return {
|
||||
"configured": bool(self.token) or self.source == "Pi 5 git credential helper",
|
||||
"host": parsed.netloc,
|
||||
"source": self.source,
|
||||
}
|
||||
|
||||
|
||||
def gitea_url() -> str:
|
||||
load_hermes_env()
|
||||
return os.getenv("REYNA_GITEA_URL") or os.getenv("GITEA_URL") or DEFAULT_GITEA_URL
|
||||
|
||||
|
||||
def _credential_from_file(path: Path, host: str) -> tuple[str, str] | None:
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
for line in lines:
|
||||
parsed = urlsplit(line.strip())
|
||||
if parsed.hostname == host and parsed.username and parsed.password:
|
||||
return unquote(parsed.username), unquote(parsed.password)
|
||||
return None
|
||||
|
||||
|
||||
def _credential_from_git_helper(host: str) -> tuple[str, str] | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "credential", "fill"],
|
||||
input=f"protocol=https\nhost={host}\n\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if result.returncode:
|
||||
return None
|
||||
values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)
|
||||
username, password = values.get("username", ""), values.get("password", "")
|
||||
return (username, password) if username and password else None
|
||||
|
||||
|
||||
def load_credentials() -> GiteaCredentials:
|
||||
load_hermes_env()
|
||||
base_url = gitea_url().rstrip("/")
|
||||
host = urlsplit(base_url).hostname or ""
|
||||
token = os.getenv("REYNA_GITEA_TOKEN") or os.getenv("GITEA_TOKEN")
|
||||
if token:
|
||||
return GiteaCredentials(os.getenv("REYNA_GITEA_USERNAME", "adolforeyna"), token, "REYNA_GITEA_TOKEN", base_url)
|
||||
|
||||
credentials_path = Path(os.getenv("GIT_CREDENTIALS_FILE", Path.home() / ".git-credentials"))
|
||||
found = _credential_from_file(credentials_path, host)
|
||||
if found:
|
||||
return GiteaCredentials(*found, "GIT_CREDENTIALS_FILE", base_url)
|
||||
|
||||
found = _credential_from_git_helper(host)
|
||||
if found:
|
||||
return GiteaCredentials(*found, "git credential helper", base_url)
|
||||
return GiteaCredentials("", "", "Pi 5 git credential helper", base_url)
|
||||
|
||||
|
||||
def redact_sensitive(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: "[REDACTED]" if key.casefold() in SENSITIVE_KEYS else redact_sensitive(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [redact_sensitive(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, credentials: GiteaCredentials | None = None, timeout: float = 20.0, transport: httpx.BaseTransport | None = None):
|
||||
self.credentials = credentials or load_credentials()
|
||||
self.remote_credential_helper = self.credentials.source == "Pi 5 git credential helper"
|
||||
if not self.credentials.token and not self.remote_credential_helper:
|
||||
raise RuntimeError("No Gitea credential is available. Set REYNA_GITEA_TOKEN or configure a supported Git credential source.")
|
||||
self.client = httpx.Client(
|
||||
base_url=self.credentials.base_url,
|
||||
timeout=timeout,
|
||||
transport=transport,
|
||||
headers={"Authorization": f"token {self.credentials.token}", "Accept": "application/json"},
|
||||
)
|
||||
|
||||
def _remote_get(self, path: str) -> Any:
|
||||
"""Use Pi 5's existing Git credential helper without returning its token."""
|
||||
script = r'''import json, subprocess, sys, urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
base_url, path = sys.argv[1:]
|
||||
host = urlsplit(base_url).hostname
|
||||
result = subprocess.run(["git", "credential", "fill"], input=f"protocol=https\nhost={host}\n\n", text=True, capture_output=True, check=False)
|
||||
values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)
|
||||
token = values.get("password", "")
|
||||
if result.returncode or not token:
|
||||
raise SystemExit("Pi 5 Git credential helper did not provide a Gitea credential")
|
||||
request = urllib.request.Request(base_url.rstrip("/") + path, headers={"Authorization": "token " + token, "Accept": "application/json"})
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
payload = json.load(response)
|
||||
def redact(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: "[REDACTED]" if key.casefold() in {"token", "access_token", "password", "authorization", "refresh_token"} else redact(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [redact(item) for item in value]
|
||||
return value
|
||||
print(json.dumps(redact(payload)))'''
|
||||
helper_host = os.getenv("REYNA_GITEA_CREDENTIAL_HOST", "pi5")
|
||||
encoded_script = base64.b64encode(script.encode("utf-8")).decode("ascii")
|
||||
remote_command = (
|
||||
"python3 -c \"import base64;exec(base64.b64decode('"
|
||||
+ encoded_script
|
||||
+ "'))\" "
|
||||
+ self.credentials.base_url
|
||||
+ " "
|
||||
+ path
|
||||
)
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", helper_host, remote_command],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError("Pi 5 Gitea credential helper request failed.")
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError("Pi 5 Gitea credential helper returned invalid JSON.") from exc
|
||||
|
||||
def _get(self, path: str) -> Any:
|
||||
if self.remote_credential_helper:
|
||||
return self._remote_get(path)
|
||||
response = self.client.get(path)
|
||||
response.raise_for_status()
|
||||
return redact_sensitive(response.json())
|
||||
|
||||
def access(self) -> Any:
|
||||
return self._get("/api/v1/user")
|
||||
|
||||
def repos(self, limit: int = 50) -> Any:
|
||||
return self._get("/api/v1/user/repos?limit=" + str(limit))
|
||||
|
||||
def repo(self, name: str) -> Any:
|
||||
owner, separator, repo = name.partition("/")
|
||||
if not separator or not owner or not repo:
|
||||
raise ValueError("Repository must be in owner/name form.")
|
||||
return self._get(f"/api/v1/repos/{owner}/{repo}")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""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 generation ──────────────────────────────────────────────────────
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
def generate_gemini(self, prompt: str, output: Optional[Path] = None, **kwargs: Any) -> Dict[str, Any]:
|
||||
from reyna_cli.media_execution import generate_gemini_image
|
||||
|
||||
return generate_gemini_image(prompt, output, **kwargs)
|
||||
|
||||
def generate_codex(self, prompt: str, output: Path, **kwargs: Any) -> Dict[str, Any]:
|
||||
from reyna_cli.media_execution import generate_codex_image
|
||||
|
||||
return generate_codex_image(prompt, output, **kwargs)
|
||||
|
||||
# ─── 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,142 @@
|
||||
"""Direct execution clients for Reyna-owned speech, audio, and image flows.
|
||||
|
||||
These paths intentionally live outside the signed privacy host: they use local
|
||||
media services, subprocesses, or explicitly configured external APIs and do not
|
||||
need Apple Automation permissions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from reyna_cli.env import load_hermes_env
|
||||
from reyna_cli.tts import synthesize_wav
|
||||
|
||||
|
||||
def _safe_filename(name: Optional[str], suffix: str) -> str:
|
||||
raw = Path(name or f"reyna-{int(time.time() * 1000)}-{uuid.uuid4().hex[:6]}{suffix}").name
|
||||
cleaned = "".join(c if c.isalnum() or c in "._-" else "-" for c in raw)
|
||||
if not cleaned:
|
||||
cleaned = f"reyna-{uuid.uuid4().hex[:8]}{suffix}"
|
||||
if not cleaned.lower().endswith(suffix):
|
||||
cleaned += suffix
|
||||
return cleaned
|
||||
|
||||
|
||||
def _json_request(url: str, payload: Optional[Dict[str, Any]] = None, *, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0) -> Any:
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, headers={"content-type": "application/json", **(headers or {})}, method="POST" if data is not None else "GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
raw = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Invalid JSON response from {url}") from exc
|
||||
|
||||
|
||||
def generate_local_tts(text: str, output: Path, *, voice: Optional[str] = None, rate: Optional[int] = None, sample_rate: int = 16000) -> Dict[str, Any]:
|
||||
"""Generate a real PCM WAV using the local Reyna TTS selection chain."""
|
||||
load_hermes_env()
|
||||
result = synthesize_wav(text, output, voice=voice, sample_rate=sample_rate)
|
||||
return {"ok": True, "source": "reyna_cli_direct", "filePath": str(result), "format": "wav pcm", "sampleRate": sample_rate, "voice": voice or os.environ.get("REYNA_TTS_VOICE") or "default", "rate": rate}
|
||||
|
||||
|
||||
def generate_kokoro(text: str, output: Optional[Path] = None, *, url: Optional[str] = None, voice: Optional[str] = None, speed: float = 1.0, lang_code: Optional[str] = None) -> Dict[str, Any]:
|
||||
load_hermes_env()
|
||||
base = (url or os.environ.get("KSAY_URL") or "http://127.0.0.1:7332").rstrip("/")
|
||||
destination = output.expanduser() if output else Path.home() / "Projects" / "MacMiniMCP" / "generated-audio" / f"ksay-{int(time.time() * 1000)}.wav"
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"text": text, "voice": voice or os.environ.get("KSAY_VOICE") or "af_heart", "speed": speed, "langCode": lang_code or os.environ.get("KSAY_LANG_CODE") or "a", "output": str(destination)}
|
||||
response = _json_request(f"{base}/say", payload, timeout=120.0)
|
||||
path_value = response.get("filePath") if isinstance(response, dict) else None
|
||||
source_path = Path(path_value).expanduser() if path_value else destination
|
||||
if source_path.exists() and source_path != destination:
|
||||
shutil.copyfile(source_path, destination)
|
||||
if not destination.exists() or destination.stat().st_size == 0:
|
||||
raise RuntimeError("Kokoro completed without creating an audio file")
|
||||
return {**(response if isinstance(response, dict) else {}), "ok": True, "source": "reyna_cli_direct", "filePath": str(destination)}
|
||||
|
||||
|
||||
def generate_voicebox(text: str, output: Optional[Path] = None, *, url: Optional[str] = None, profile: str = "Aiden", engine: Optional[str] = None, language: str = "en", timeout: float = 120.0) -> Dict[str, Any]:
|
||||
load_hermes_env()
|
||||
base = (url or os.environ.get("VOICEBOX_URL") or "http://127.0.0.1:17493").rstrip("/")
|
||||
body: Dict[str, Any] = {"profile_id": profile, "text": text[:1000], "language": language}
|
||||
if engine:
|
||||
body["engine"] = engine
|
||||
response = _json_request(f"{base}/generate", body, timeout=timeout)
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("Voicebox returned an unexpected response")
|
||||
started = time.monotonic()
|
||||
while response.get("status") == "generating" and time.monotonic() - started < timeout:
|
||||
time.sleep(0.5)
|
||||
response = _json_request(f"{base}/history/{response.get('id')}", None, timeout=30.0)
|
||||
if response.get("status") == "failed":
|
||||
raise RuntimeError(str(response.get("error") or "Voicebox generation failed"))
|
||||
audio_path = response.get("audio_path")
|
||||
if not audio_path:
|
||||
raise RuntimeError("Voicebox returned no audio_path")
|
||||
source = Path(str(audio_path)).expanduser()
|
||||
candidates = [source]
|
||||
if not source.is_absolute():
|
||||
root = Path.home() / "Library" / "Application Support" / "sh.voicebox.app"
|
||||
candidates.extend([root / source, root / "generations" / source.name])
|
||||
source = next((candidate for candidate in candidates if candidate.exists()), source)
|
||||
if not source.exists():
|
||||
raise RuntimeError(f"Voicebox audio file not found: {audio_path}")
|
||||
destination = output.expanduser() if output else Path.home() / "Projects" / "MacMiniMCP" / "generated-audio" / source.name
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, destination)
|
||||
return {**response, "ok": True, "source": "reyna_cli_direct", "filePath": str(destination)}
|
||||
|
||||
|
||||
def generate_gemini_image(prompt: str, output: Optional[Path] = None, *, model: str = "gemini-3.1-flash-image", aspect_ratio: Optional[str] = None, image_size: Optional[str] = None) -> Dict[str, Any]:
|
||||
load_hermes_env()
|
||||
key = os.environ.get("GEMINI_API_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("GEMINI_API_KEY is required")
|
||||
response_format: Dict[str, Any] = {"type": "image", "mime_type": "image/png"}
|
||||
if aspect_ratio:
|
||||
response_format["aspect_ratio"] = aspect_ratio
|
||||
if image_size:
|
||||
response_format["image_size"] = image_size
|
||||
body = {"model": model, "input": [{"type": "text", "text": prompt}], "response_format": response_format}
|
||||
response = _json_request("https://generativelanguage.googleapis.com/v1beta/interactions", body, headers={"x-goog-api-key": key}, timeout=180.0)
|
||||
image = response.get("output_image") if isinstance(response, dict) else None
|
||||
if not isinstance(image, dict) or not image.get("data"):
|
||||
raise RuntimeError("Gemini API did not return output_image.data")
|
||||
destination = output.expanduser() if output else Path.home() / "Projects" / "MacMiniMCP" / "generated-images" / _safe_filename(None, ".png")
|
||||
destination = destination.with_suffix(".png")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(base64.b64decode(image["data"]))
|
||||
return {"ok": True, "source": "reyna_cli_direct", "path": str(destination), "model": model, "mimeType": image.get("mime_type", "image/png"), "interactionId": response.get("id")}
|
||||
|
||||
|
||||
def generate_codex_image(prompt: str, output: Path, *, size: Optional[str] = None, quality: Optional[str] = None, style: Optional[str] = None, reference_image: Optional[Path] = None, codex_path: Optional[str] = None, timeout: int = 600) -> Dict[str, Any]:
|
||||
destination = output.expanduser().resolve()
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
codex = codex_path or os.environ.get("CODEX_CLI_PATH") or "codex"
|
||||
details = "\n".join(x for x in [f"Requested size/aspect: {size}" if size else "", f"Requested quality: {quality}" if quality else "", f"Requested style: {style}" if style else ""])
|
||||
worker_prompt = "\n\n".join(x for x in ["Use $imagegen to generate exactly one raster image.", f"Save the final image file at this exact absolute path: {destination}", "Do not modify any other files.", f"Image prompt:\n{prompt}", details] if x)
|
||||
args = ["exec", "--ephemeral", "--sandbox", "workspace-write", "--enable", "image_generation", "-C", str(Path.cwd())]
|
||||
if reference_image:
|
||||
args.extend(["--image", str(reference_image.expanduser())])
|
||||
args.append(worker_prompt)
|
||||
result = subprocess.run([codex, *args], capture_output=True, text=True, timeout=timeout, check=False)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Codex image generation failed: {(result.stderr or result.stdout).strip()[:1000]}")
|
||||
if not destination.exists() or destination.stat().st_size == 0:
|
||||
raise RuntimeError(f"Codex completed but did not create {destination}")
|
||||
return {"ok": True, "source": "reyna_cli_direct", "path": str(destination), "bytes": destination.stat().st_size, "codexCliPath": codex}
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Direct Apple Notes automation for the mutable Reyna CLI.
|
||||
|
||||
The scripts are static JXA programs. User data is passed only as one JSON
|
||||
argument to ``osascript`` so note content is never interpolated into source.
|
||||
This deliberately stays outside the stable Swift host: Python-only CLI changes
|
||||
do not require rebuilding or re-signing Reyna CLI.app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Any, Callable, Optional, Sequence
|
||||
|
||||
|
||||
class NotesAutomationError(RuntimeError):
|
||||
"""Apple Notes could not complete the requested automation operation."""
|
||||
|
||||
|
||||
Runner = Callable[..., Any]
|
||||
|
||||
|
||||
_NOTES_LIST_SCRIPT = r'''function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Notes.app");
|
||||
const query = (input.query || "").toLowerCase();
|
||||
const folderName = input.folder || "";
|
||||
const found = [];
|
||||
const accounts = app.accounts();
|
||||
for (let a = 0; a < accounts.length && found.length < input.limit; a++) {
|
||||
const folders = accounts[a].folders();
|
||||
for (let f = 0; f < folders.length && found.length < input.limit; f++) {
|
||||
const folder = folders[f];
|
||||
const currentFolder = String(folder.name());
|
||||
if (folderName && currentFolder !== folderName) continue;
|
||||
const notes = folder.notes();
|
||||
for (let n = 0; n < notes.length && found.length < input.limit; n++) {
|
||||
const note = notes[n];
|
||||
let title = "";
|
||||
let text = "";
|
||||
try { title = String(note.name()); } catch (_) {}
|
||||
try { text = String(note.plaintext()); } catch (_) {}
|
||||
if (query && (title + "\n" + text).toLowerCase().indexOf(query) === -1) continue;
|
||||
found.push({
|
||||
id: String(note.id()),
|
||||
title: title,
|
||||
folder: currentFolder,
|
||||
modifiedAt: note.modificationDate().toISOString(),
|
||||
preview: input.includePreview ? text.slice(0, 180) : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return JSON.stringify(found);
|
||||
}'''
|
||||
|
||||
_NOTES_READ_SCRIPT = r'''function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Notes.app");
|
||||
const accounts = app.accounts();
|
||||
for (let a = 0; a < accounts.length; a++) {
|
||||
const folders = accounts[a].folders();
|
||||
for (let f = 0; f < folders.length; f++) {
|
||||
const notes = folders[f].notes();
|
||||
for (let n = 0; n < notes.length; n++) {
|
||||
const note = notes[n];
|
||||
if (String(note.id()) === input.id) {
|
||||
return JSON.stringify({
|
||||
id: String(note.id()),
|
||||
title: String(note.name()),
|
||||
folder: String(folders[f].name()),
|
||||
bodyHtml: String(note.body()),
|
||||
plaintext: String(note.plaintext()),
|
||||
createdAt: note.creationDate().toISOString(),
|
||||
modifiedAt: note.modificationDate().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Note not found");
|
||||
}'''
|
||||
|
||||
_NOTES_CREATE_SCRIPT = r'''function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Notes.app");
|
||||
const escapeHtml = value => String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
const body = escapeHtml(input.body).replace(/\n/g, "<br>");
|
||||
const html = "<h1>" + escapeHtml(input.title) + "</h1><div>" + body + "</div>";
|
||||
let destination = null;
|
||||
const accounts = app.accounts();
|
||||
for (let a = 0; a < accounts.length && !destination; a++) {
|
||||
const folders = accounts[a].folders();
|
||||
for (let f = 0; f < folders.length; f++) {
|
||||
if (!input.folder || String(folders[f].name()) === input.folder) {
|
||||
destination = folders[f];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!destination) throw new Error("Notes destination folder not found");
|
||||
const note = app.Note({body: html});
|
||||
destination.notes.push(note);
|
||||
return JSON.stringify({id: String(note.id()), title: String(note.name()), folder: String(destination.name())});
|
||||
}'''
|
||||
|
||||
|
||||
_NOTES_UPDATE_SCRIPT = r'''function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Notes.app");
|
||||
const escapeHtml = value => String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
const accounts = app.accounts();
|
||||
for (let a = 0; a < accounts.length; a++) {
|
||||
const folders = accounts[a].folders();
|
||||
for (let f = 0; f < folders.length; f++) {
|
||||
const notes = folders[f].notes();
|
||||
for (let n = 0; n < notes.length; n++) {
|
||||
const note = notes[n];
|
||||
if (String(note.id()) !== input.id) continue;
|
||||
const oldTitle = String(note.name());
|
||||
const oldText = String(note.plaintext());
|
||||
const fallbackBody = oldText.startsWith(oldTitle) ? oldText.slice(oldTitle.length).replace(/^\\n+/, "") : oldText;
|
||||
const title = input.title === null ? oldTitle : input.title;
|
||||
const body = input.body === null ? fallbackBody : input.body;
|
||||
note.body = "<h1>" + escapeHtml(title) + "</h1><div>" + escapeHtml(body).replace(/\\n/g, "<br>") + "</div>";
|
||||
return JSON.stringify({id: String(note.id()), title: String(note.name()), folder: String(folders[f].name())});
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Note not found");
|
||||
}'''
|
||||
|
||||
|
||||
_NOTES_DELETE_SCRIPT = r'''function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Notes.app");
|
||||
const accounts = app.accounts();
|
||||
for (let a = 0; a < accounts.length; a++) {
|
||||
const folders = accounts[a].folders();
|
||||
for (let f = 0; f < folders.length; f++) {
|
||||
const notes = folders[f].notes();
|
||||
for (let n = 0; n < notes.length; n++) {
|
||||
const note = notes[n];
|
||||
if (String(note.id()) !== input.id) continue;
|
||||
const id = String(note.id());
|
||||
note.delete();
|
||||
return JSON.stringify({id: id, deleted: true});
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Note not found");
|
||||
}'''
|
||||
|
||||
|
||||
def _run_jxa(script: str, payload: dict[str, Any], *, runner: Runner = subprocess.run) -> Any:
|
||||
arguments: Sequence[str] = [
|
||||
"/usr/bin/osascript",
|
||||
"-l",
|
||||
"JavaScript",
|
||||
"-e",
|
||||
script,
|
||||
"--",
|
||||
json.dumps(payload, separators=(",", ":")),
|
||||
]
|
||||
try:
|
||||
result = runner(arguments, capture_output=True, text=True, timeout=30, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise NotesAutomationError(f"Apple Notes automation could not start: {exc}") from exc
|
||||
if result.returncode:
|
||||
detail = (result.stderr or result.stdout or "Apple Notes automation failed").strip()
|
||||
raise NotesAutomationError(detail[:1000])
|
||||
try:
|
||||
return json.loads((result.stdout or "").strip())
|
||||
except json.JSONDecodeError as exc:
|
||||
raise NotesAutomationError("Apple Notes automation returned invalid JSON") from exc
|
||||
|
||||
|
||||
def _bounded_text(value: str, field: str, maximum: int) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if len(value) > maximum:
|
||||
raise ValueError(f"{field} exceeds maximum length {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def list_notes(
|
||||
*,
|
||||
query: Optional[str] = None,
|
||||
folder: Optional[str] = None,
|
||||
include_preview: bool = False,
|
||||
limit: int = 20,
|
||||
runner: Runner = subprocess.run,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not 1 <= limit <= 100:
|
||||
raise ValueError("limit must be between 1 and 100")
|
||||
result = _run_jxa(
|
||||
_NOTES_LIST_SCRIPT,
|
||||
{"query": query or "", "folder": folder or "", "includePreview": include_preview, "limit": limit},
|
||||
runner=runner,
|
||||
)
|
||||
if not isinstance(result, list):
|
||||
raise NotesAutomationError("Apple Notes list response was not a list")
|
||||
return result
|
||||
|
||||
|
||||
def read_note(note_id: str, *, runner: Runner = subprocess.run) -> dict[str, Any]:
|
||||
result = _run_jxa(_NOTES_READ_SCRIPT, {"id": _bounded_text(note_id, "note id", 1024)}, runner=runner)
|
||||
if not isinstance(result, dict):
|
||||
raise NotesAutomationError("Apple Notes read response was not an object")
|
||||
return result
|
||||
|
||||
|
||||
def create_note(
|
||||
title: str,
|
||||
body: str = "",
|
||||
*,
|
||||
folder: Optional[str] = None,
|
||||
runner: Runner = subprocess.run,
|
||||
) -> dict[str, Any]:
|
||||
title = _bounded_text(title, "title", 500)
|
||||
if len(body) > 100_000:
|
||||
raise ValueError("body exceeds maximum length 100000")
|
||||
result = _run_jxa(_NOTES_CREATE_SCRIPT, {"title": title, "body": body, "folder": folder or ""}, runner=runner)
|
||||
if not isinstance(result, dict):
|
||||
raise NotesAutomationError("Apple Notes create response was not an object")
|
||||
return result
|
||||
|
||||
|
||||
def update_note(
|
||||
note_id: str,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
body: Optional[str] = None,
|
||||
runner: Runner = subprocess.run,
|
||||
) -> dict[str, Any]:
|
||||
note_id = _bounded_text(note_id, "note id", 1024)
|
||||
if title is None and body is None:
|
||||
raise ValueError("Specify title or body to update")
|
||||
if title is not None:
|
||||
title = _bounded_text(title, "title", 500)
|
||||
if body is not None and len(body) > 100_000:
|
||||
raise ValueError("body exceeds maximum length 100000")
|
||||
result = _run_jxa(_NOTES_UPDATE_SCRIPT, {"id": note_id, "title": title, "body": body}, runner=runner)
|
||||
if not isinstance(result, dict):
|
||||
raise NotesAutomationError("Apple Notes update response was not an object")
|
||||
return result
|
||||
|
||||
|
||||
def delete_note(note_id: str, *, runner: Runner = subprocess.run) -> dict[str, Any]:
|
||||
result = _run_jxa(_NOTES_DELETE_SCRIPT, {"id": _bounded_text(note_id, "note id", 1024)}, runner=runner)
|
||||
if not isinstance(result, dict) or result.get("deleted") is not True:
|
||||
raise NotesAutomationError("Apple Notes delete response was not confirmed")
|
||||
return result
|
||||
@@ -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
@@ -0,0 +1,157 @@
|
||||
"""Direct local Qwen3-TTS voice-cloning support for reyna-cli.
|
||||
|
||||
This follows the QwenTTSService implementation pulled from the VoiceAgent
|
||||
Gitea repository, but exposes it as a synchronous CLI client and keeps the
|
||||
MLX imports lazy so config/help commands remain offline-safe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
MODEL_ID = "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
|
||||
DEFAULT_REF_AUDIO = Path.home() / "Downloads" / "jv_voice_sample.wav"
|
||||
JV_SAMPLE_TEXT = (
|
||||
"I have completed a diagnostic scan of your current schedule, and it appears "
|
||||
"several conflicts have arisen. While I have taken the liberty of reorganizing "
|
||||
"your morning appointments to ensure maximum efficiency, I cannot account for "
|
||||
"human fatigue. Perhaps a second cup of coffee would be a logical next step."
|
||||
)
|
||||
|
||||
|
||||
class Qwen3TTSDirectClient:
|
||||
"""Generate JV-cloned speech with the local MLX Qwen3-TTS Base model."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model_id: Optional[str] = None,
|
||||
ref_audio: Optional[Path] = None,
|
||||
ref_text: Optional[str] = None,
|
||||
instruct: Optional[str] = None,
|
||||
temperature: float = 0.3,
|
||||
) -> None:
|
||||
self.model_id = (
|
||||
model_id
|
||||
or os.environ.get("REYNA_QWEN3_TTS_MODEL")
|
||||
or MODEL_ID
|
||||
).strip()
|
||||
configured_ref = os.environ.get("REYNA_QWEN3_TTS_REF_AUDIO")
|
||||
self.ref_audio = Path(
|
||||
ref_audio or configured_ref or DEFAULT_REF_AUDIO
|
||||
).expanduser()
|
||||
self.ref_text = (
|
||||
ref_text
|
||||
if ref_text is not None
|
||||
else os.environ.get("REYNA_QWEN3_TTS_REF_TEXT") or JV_SAMPLE_TEXT
|
||||
).strip()
|
||||
self.instruct = (
|
||||
instruct
|
||||
if instruct is not None
|
||||
else os.environ.get("REYNA_QWEN3_TTS_INSTRUCT")
|
||||
)
|
||||
if self.instruct is not None:
|
||||
self.instruct = self.instruct.strip()[:2000] or None
|
||||
self.temperature = float(temperature)
|
||||
if not 0.0 < self.temperature <= 2.0:
|
||||
raise ValueError("temperature must be > 0 and <= 2.0")
|
||||
self._model: Any = None
|
||||
|
||||
def config_status(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"engine": "qwen3-tts",
|
||||
"model_id": self.model_id,
|
||||
"ref_audio": str(self.ref_audio),
|
||||
"ref_audio_exists": self.ref_audio.is_file(),
|
||||
"ref_audio_size_bytes": self.ref_audio.stat().st_size
|
||||
if self.ref_audio.is_file()
|
||||
else None,
|
||||
"ref_text_configured": bool(self.ref_text),
|
||||
"instruct_configured": bool(self.instruct),
|
||||
"temperature": self.temperature,
|
||||
"source": "direct",
|
||||
"offline": True,
|
||||
"note": "Uses MLX locally; config_status does not load the model or contact a service.",
|
||||
}
|
||||
|
||||
def validate_generate(self, text: str) -> Dict[str, Any]:
|
||||
if not text or not text.strip():
|
||||
raise ValueError("text required")
|
||||
if not self.ref_audio.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"reference audio not found: {self.ref_audio}"
|
||||
)
|
||||
if not self.ref_text:
|
||||
raise ValueError("reference audio transcript required")
|
||||
return {
|
||||
"text": text.strip()[:8000],
|
||||
"engine": "qwen3-tts",
|
||||
"model_id": self.model_id,
|
||||
"ref_audio": str(self.ref_audio),
|
||||
"offline_validation": True,
|
||||
}
|
||||
|
||||
def _ensure_model_loaded(self) -> None:
|
||||
if self._model is not None:
|
||||
return
|
||||
from mlx_audio.tts import load_model
|
||||
|
||||
self._model = load_model(self.model_id)
|
||||
|
||||
def generate(self, text: str, output: Path) -> Dict[str, Any]:
|
||||
"""Generate a WAV file and return verified output metadata."""
|
||||
self.validate_generate(text)
|
||||
output = Path(output).expanduser()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._ensure_model_loaded()
|
||||
|
||||
from mlx_audio.tts.generate import generate_audio
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="reyna-qwen3-") as workdir:
|
||||
generate_audio(
|
||||
text=text.strip()[:8000],
|
||||
model=self._model,
|
||||
ref_audio=str(self.ref_audio),
|
||||
ref_text=self.ref_text,
|
||||
instruct=self.instruct,
|
||||
temperature=self.temperature,
|
||||
output_path=workdir,
|
||||
file_prefix="audio",
|
||||
audio_format="wav",
|
||||
verbose=False,
|
||||
play=False,
|
||||
)
|
||||
candidates = sorted(Path(workdir).glob("audio*.wav"))
|
||||
if not candidates:
|
||||
raise RuntimeError(
|
||||
"Qwen3-TTS returned without creating a WAV file"
|
||||
)
|
||||
shutil.copyfile(candidates[0], output)
|
||||
|
||||
if not output.is_file() or output.stat().st_size <= 44:
|
||||
raise RuntimeError(f"Qwen3-TTS output is missing or empty: {output}")
|
||||
|
||||
with wave.open(str(output), "rb") as wav:
|
||||
sample_rate = wav.getframerate()
|
||||
frames = wav.getnframes()
|
||||
channels = wav.getnchannels()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"engine": "qwen3-tts",
|
||||
"model_id": self.model_id,
|
||||
"voice": "qwen_jv",
|
||||
"ref_audio": str(self.ref_audio),
|
||||
"path": str(output),
|
||||
"size_bytes": output.stat().st_size,
|
||||
"sample_rate": sample_rate,
|
||||
"channels": channels,
|
||||
"duration_seconds": round(frames / sample_rate, 3)
|
||||
if sample_rate
|
||||
else 0.0,
|
||||
"source": "direct",
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Launch mutable Reyna CLI Python logic through the signed native app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, Sequence
|
||||
|
||||
from reyna_cli.app_bundle import app_bundle_executable_path
|
||||
|
||||
|
||||
class SignedLauncherError(RuntimeError):
|
||||
"""The signed app required for a privileged Python launch is unavailable."""
|
||||
|
||||
|
||||
def signed_app_executable() -> Path:
|
||||
return app_bundle_executable_path()
|
||||
|
||||
|
||||
def run_signed_python(
|
||||
arguments: Sequence[str],
|
||||
*,
|
||||
executable: Path | None = None,
|
||||
runner: Callable[..., object] = subprocess.run,
|
||||
) -> int:
|
||||
"""Run Python CLI arguments under the stable signed host and return its exit code."""
|
||||
app_executable = Path(executable) if executable is not None else signed_app_executable()
|
||||
if not app_executable.is_file():
|
||||
raise SignedLauncherError(f"signed Reyna CLI app executable is missing: {app_executable}")
|
||||
|
||||
result = runner([str(app_executable), "--python", *arguments], check=False)
|
||||
return int(getattr(result, "returncode", 1))
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Direct Apple SpeechTranscriber file execution for Reyna CLI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
_SWIFT_SOURCE = r'''import Speech
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct ReynaTranscriber {
|
||||
static func main() async {
|
||||
let args = CommandLine.arguments
|
||||
guard args.count >= 2 else { fail("audio path is required", code: 2) }
|
||||
let audioPath = args[1]
|
||||
let localeID = args.count >= 3 ? args[2] : "en-US"
|
||||
guard FileManager.default.fileExists(atPath: audioPath) else { fail("audio file not found", code: 3) }
|
||||
guard SpeechTranscriber.isAvailable else { fail("SpeechTranscriber is unavailable", code: 4) }
|
||||
let requested = Locale(identifier: localeID)
|
||||
guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: requested) else { fail("unsupported locale", code: 5) }
|
||||
let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
|
||||
do {
|
||||
if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
||||
try await request.downloadAndInstall()
|
||||
}
|
||||
let file = try AVAudioFile(forReading: URL(fileURLWithPath: audioPath))
|
||||
let analyzer = try await SpeechAnalyzer(inputAudioFile: file, modules: [transcriber], finishAfterFile: true)
|
||||
_ = analyzer
|
||||
var segments: [String] = []
|
||||
for try await result in transcriber.results {
|
||||
let text = String(result.text.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty { segments.append(text) }
|
||||
}
|
||||
let payload: [String: Any] = [
|
||||
"ok": true,
|
||||
"engine": "SpeechAnalyzer+SpeechTranscriber",
|
||||
"locale": locale.identifier,
|
||||
"requestedLocale": localeID,
|
||||
"transcript": segments.joined(separator: " "),
|
||||
"segments": segments,
|
||||
"audioPath": audioPath,
|
||||
"isAvailable": SpeechTranscriber.isAvailable
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])
|
||||
FileHandle.standardOutput.write(data)
|
||||
} catch {
|
||||
fail(String(describing: error), code: 6)
|
||||
}
|
||||
}
|
||||
static func fail(_ message: String, code: Int32) -> Never {
|
||||
let payload: [String: Any] = ["ok": false, "error": message]
|
||||
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []) { FileHandle.standardOutput.write(data) }
|
||||
exit(code)
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class SpeechExecutionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _cache_dir() -> Path:
|
||||
return Path.home() / "Library" / "Application Support" / "reyna-cli" / "speech"
|
||||
|
||||
|
||||
def _binary_path() -> Path:
|
||||
digest = hashlib.sha256(_SWIFT_SOURCE.encode()).hexdigest()[:16]
|
||||
return _cache_dir() / f"transcriber-{digest}"
|
||||
|
||||
|
||||
def _ensure_binary() -> Path:
|
||||
cached = _binary_path()
|
||||
if cached.exists() and os.access(cached, os.X_OK):
|
||||
return cached
|
||||
swiftc = shutil.which("swiftc") or "/usr/bin/swiftc"
|
||||
if not Path(swiftc).exists():
|
||||
raise SpeechExecutionError("swiftc is required for SpeechTranscriber file execution")
|
||||
_cache_dir().mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="reyna-speech-build-") as tmp:
|
||||
source = Path(tmp) / "Transcriber.swift"
|
||||
binary = Path(tmp) / "transcriber"
|
||||
source.write_text(_SWIFT_SOURCE, encoding="utf-8")
|
||||
result = subprocess.run([swiftc, "-O", "-parse-as-library", str(source), "-o", str(binary), "-framework", "AVFoundation", "-framework", "Speech"], capture_output=True, text=True, timeout=120, check=False)
|
||||
if result.returncode != 0:
|
||||
raise SpeechExecutionError(f"swiftc failed: {(result.stderr or result.stdout).strip()[:2000]}")
|
||||
shutil.copyfile(binary, cached)
|
||||
cached.chmod(0o700)
|
||||
return cached
|
||||
|
||||
|
||||
def transcribe_file(audio_path: Path, *, locale: str = "en-US", timeout: int = 300) -> Dict[str, Any]:
|
||||
audio = audio_path.expanduser().resolve()
|
||||
if not audio.is_file():
|
||||
raise SpeechExecutionError(f"audio file not found: {audio}")
|
||||
binary = _ensure_binary()
|
||||
result = subprocess.run([str(binary), str(audio), locale], capture_output=True, text=True, timeout=timeout, check=False)
|
||||
raw = (result.stdout or "").strip()
|
||||
try:
|
||||
payload = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SpeechExecutionError(f"SpeechTranscriber returned invalid JSON: {raw[:500]}") from exc
|
||||
if result.returncode != 0 or not payload.get("ok"):
|
||||
raise SpeechExecutionError(str(payload.get("error") or result.stderr.strip() or "SpeechTranscriber failed"))
|
||||
return {**payload, "source": "reyna_cli_direct", "binary": str(binary)}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Persistent direct SpeechAnalyzer pipe session for live audio chunks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
_SWIFT_SOURCE = r'''import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
@main
|
||||
struct ReynaLiveTranscriber {
|
||||
static func main() async {
|
||||
let args = CommandLine.arguments
|
||||
let localeID = args.count > 1 ? args[1] : "en-US"
|
||||
guard SpeechTranscriber.isAvailable else { exit(4) }
|
||||
guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: localeID)) else { exit(5) }
|
||||
let warm = SpeechTranscriber(locale: locale, preset: .transcription)
|
||||
do {
|
||||
if let request = try await AssetInventory.assetInstallationRequest(supporting: [warm]) { try await request.downloadAndInstall() }
|
||||
} catch { exit(6) }
|
||||
while true {
|
||||
guard let header = readExact(4) else { break }
|
||||
let length = header.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
|
||||
if length == 0 || length > 20_000_000 { break }
|
||||
guard let bytes = readExact(Int(length)) else { break }
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("reyna-live-\(UUID().uuidString).wav")
|
||||
do {
|
||||
try bytes.write(to: url)
|
||||
let file = try AVAudioFile(forReading: url)
|
||||
let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
|
||||
let analyzer = try await SpeechAnalyzer(inputAudioFile: file, modules: [transcriber], finishAfterFile: true)
|
||||
_ = analyzer
|
||||
var text = ""
|
||||
for try await result in transcriber.results { text += " " + String(result.text.characters) }
|
||||
let output: [String: Any] = ["ok": true, "event": "final", "text": text.trimmingCharacters(in: .whitespacesAndNewlines)]
|
||||
let data = try JSONSerialization.data(withJSONObject: output, options: [])
|
||||
FileHandle.standardOutput.write(data); FileHandle.standardOutput.write(Data([10]))
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
let output: [String: Any] = ["ok": false, "error": String(describing: error)]
|
||||
if let data = try? JSONSerialization.data(withJSONObject: output, options: []) { FileHandle.standardOutput.write(data); FileHandle.standardOutput.write(Data([10])) }
|
||||
}
|
||||
}
|
||||
}
|
||||
static func readExact(_ count: Int) -> Data? {
|
||||
var data = Data(); data.reserveCapacity(count)
|
||||
while data.count < count {
|
||||
let chunk = FileHandle.standardInput.readData(ofLength: count - data.count)
|
||||
if chunk.isEmpty { return nil }
|
||||
data.append(chunk)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class SpeechLiveError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _binary_path() -> Path:
|
||||
digest = hashlib.sha256(_SWIFT_SOURCE.encode()).hexdigest()[:16]
|
||||
return Path.home() / "Library" / "Application Support" / "reyna-cli" / "speech" / f"live-{digest}"
|
||||
|
||||
|
||||
def _ensure_binary() -> Path:
|
||||
cached = _binary_path()
|
||||
if cached.exists() and os.access(cached, os.X_OK):
|
||||
return cached
|
||||
swiftc = shutil.which("swiftc") or "/usr/bin/swiftc"
|
||||
if not Path(swiftc).exists():
|
||||
raise SpeechLiveError("swiftc is required for live SpeechTranscriber")
|
||||
cached.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="reyna-live-build-") as tmp:
|
||||
source = Path(tmp) / "Live.swift"
|
||||
binary = Path(tmp) / "live-transcriber"
|
||||
source.write_text(_SWIFT_SOURCE, encoding="utf-8")
|
||||
result = subprocess.run([swiftc, "-O", "-parse-as-library", str(source), "-o", str(binary), "-framework", "AVFoundation", "-framework", "Speech"], capture_output=True, text=True, timeout=120, check=False)
|
||||
if result.returncode != 0:
|
||||
raise SpeechLiveError(f"swiftc failed: {(result.stderr or result.stdout).strip()[:2000]}")
|
||||
shutil.copyfile(binary, cached)
|
||||
cached.chmod(0o700)
|
||||
return cached
|
||||
|
||||
|
||||
class SpeechLiveSession:
|
||||
"""One persistent warmed SpeechTranscriber process, safe for sequential chunks."""
|
||||
def __init__(self, locale: str = "en-US") -> None:
|
||||
self.locale = locale
|
||||
self.binary = _ensure_binary()
|
||||
self.process: Optional[subprocess.Popen[str]] = None
|
||||
self.events: queue.Queue[Dict[str, Any]] = queue.Queue()
|
||||
self.reader: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def start(self) -> None:
|
||||
if self.process and self.process.poll() is None:
|
||||
return
|
||||
self.process = subprocess.Popen([str(self.binary), self.locale], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=False)
|
||||
assert self.process.stdout is not None
|
||||
def read_lines() -> None:
|
||||
for raw in self.process.stdout:
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
if isinstance(value, dict):
|
||||
self.events.put(value)
|
||||
except Exception:
|
||||
continue
|
||||
self.reader = threading.Thread(target=read_lines, daemon=True)
|
||||
self.reader.start()
|
||||
|
||||
def transcribe_chunk(self, wav_bytes: bytes, timeout: float = 10.0) -> Dict[str, Any]:
|
||||
if not wav_bytes:
|
||||
raise SpeechLiveError("audio chunk is empty")
|
||||
with self._lock:
|
||||
self.start()
|
||||
assert self.process is not None and self.process.stdin is not None
|
||||
try:
|
||||
self.process.stdin.write(struct.pack(">I", len(wav_bytes)) + wav_bytes)
|
||||
self.process.stdin.flush()
|
||||
except Exception as exc:
|
||||
raise SpeechLiveError(f"live SpeechTranscriber input failed: {exc}") from exc
|
||||
try:
|
||||
event = self.events.get(timeout=timeout)
|
||||
except queue.Empty as exc:
|
||||
raise SpeechLiveError("live SpeechTranscriber timed out") from exc
|
||||
if not event.get("ok", False):
|
||||
raise SpeechLiveError(str(event.get("error") or "live SpeechTranscriber failed"))
|
||||
return {**event, "source": "reyna_cli_direct", "locale": self.locale}
|
||||
|
||||
def close(self) -> None:
|
||||
process, self.process = self.process, None
|
||||
if not process:
|
||||
return
|
||||
try:
|
||||
if process.stdin:
|
||||
process.stdin.write(struct.pack(">I", 0)); process.stdin.flush(); process.stdin.close()
|
||||
process.wait(timeout=3)
|
||||
except Exception:
|
||||
process.kill()
|
||||
|
||||
def __enter__(self) -> "SpeechLiveSession":
|
||||
self.start(); return self
|
||||
|
||||
def __exit__(self, *_: Any) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
class TactilityClient:
|
||||
"""Small direct client for the Tactility firmware HTTP API."""
|
||||
|
||||
def __init__(self, base_url: str, timeout: float = 30.0, transport: Optional[httpx.BaseTransport] = None):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.client = httpx.Client(timeout=timeout, transport=transport, follow_redirects=True)
|
||||
|
||||
def _response(self, response: httpx.Response) -> Any:
|
||||
response.raise_for_status()
|
||||
if not response.content:
|
||||
return {"ok": True, "status_code": response.status_code}
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "json" in content_type:
|
||||
return response.json()
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return {"ok": True, "status_code": response.status_code, "response": response.text}
|
||||
|
||||
def get(self, path: str, **kwargs: Any) -> Any:
|
||||
return self._response(self.client.get(f"{self.base_url}{path}", **kwargs))
|
||||
|
||||
def post(self, path: str, **kwargs: Any) -> Any:
|
||||
return self._response(self.client.post(f"{self.base_url}{path}", **kwargs))
|
||||
|
||||
def sysinfo(self) -> Any:
|
||||
return self.get("/api/sysinfo")
|
||||
|
||||
def apps(self) -> Any:
|
||||
return self.get("/api/apps")
|
||||
|
||||
def install_app(self, app_path: Path) -> Any:
|
||||
with app_path.open("rb") as handle:
|
||||
response = self.client.put(
|
||||
f"{self.base_url}/api/apps/install",
|
||||
files={"file": (app_path.name, handle, "application/octet-stream")},
|
||||
)
|
||||
return self._response(response)
|
||||
|
||||
def run_app(self, app_id: str) -> Any:
|
||||
return self.post("/api/apps/run", params={"id": app_id})
|
||||
|
||||
def fs_list(self, path: str = "/") -> Any:
|
||||
return self.get("/fs/list", params={"path": path})
|
||||
|
||||
def fs_mkdir(self, path: str) -> Any:
|
||||
return self.post("/fs/mkdir", params={"path": path})
|
||||
|
||||
def fs_upload(self, local_path: Path, remote_path: str) -> Any:
|
||||
data = local_path.read_bytes()
|
||||
return self.post(
|
||||
"/fs/upload",
|
||||
params={"path": remote_path},
|
||||
content=data,
|
||||
headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(data))},
|
||||
)
|
||||
|
||||
def fs_download(self, remote_path: str, local_path: Path) -> Dict[str, Any]:
|
||||
response = self.client.get(f"{self.base_url}/fs/download", params={"path": remote_path})
|
||||
response.raise_for_status()
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_bytes(response.content)
|
||||
return {"ok": True, "path": str(local_path), "bytes": len(response.content)}
|
||||
|
||||
def fs_read(self, remote_path: str) -> str:
|
||||
response = self.client.get(f"{self.base_url}/fs/download", params={"path": remote_path})
|
||||
response.raise_for_status()
|
||||
return response.content.decode("utf-8", errors="replace")
|
||||
|
||||
def fs_delete(self, path: str) -> Any:
|
||||
return self.post("/fs/delete", params={"path": path})
|
||||
|
||||
def fs_rename(self, path: str, new_name: str) -> Any:
|
||||
return self.post("/fs/rename", params={"path": path, "newName": new_name})
|
||||
|
||||
def screen_raw(self, pixels: bytes, width: int, height: int) -> Any:
|
||||
expected = width * height * 2
|
||||
if len(pixels) != expected:
|
||||
raise ValueError(f"RGB565 frame is {len(pixels)} bytes; expected {expected} for {width}x{height}")
|
||||
response = self.client.post(
|
||||
f"{self.base_url}/api/screen/raw",
|
||||
params={"w": width, "h": height},
|
||||
content=pixels,
|
||||
headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(pixels))},
|
||||
)
|
||||
return self._response(response)
|
||||
|
||||
def screen_clear_raw(self, width: int, height: int) -> Any:
|
||||
return self.screen_raw(bytes(width * height * 2), width, height)
|
||||
|
||||
@staticmethod
|
||||
def _rgb565(image: Image.Image) -> bytes:
|
||||
pixels = bytearray()
|
||||
for red, green, blue in image.convert("RGB").get_flattened_data():
|
||||
pixels.extend((((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3)).to_bytes(2, "big"))
|
||||
return bytes(pixels)
|
||||
|
||||
def screen_text(self, message: str, width: int, height: int, clear_first: bool = True) -> Dict[str, Any]:
|
||||
clear_result = self.screen_clear_raw(width, height) if clear_first else None
|
||||
image = Image.new("RGB", (width, height), (14, 18, 38))
|
||||
draw = ImageDraw.Draw(image)
|
||||
font_path = next((path for path in (
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
"/Library/Fonts/Arial.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
) if Path(path).exists()), None)
|
||||
font_size = max(16, round(width / 16))
|
||||
font = ImageFont.truetype(font_path, font_size) if font_path else ImageFont.load_default()
|
||||
margin = max(1, min(8, min(width, height) // 10))
|
||||
draw.rectangle((margin, margin, width - margin - 1, height - margin - 1), outline=(90, 110, 180), width=2)
|
||||
draw.multiline_text((width // 2, height // 2), message, font=font, fill=(245, 245, 232), anchor="mm", align="center", spacing=5)
|
||||
write_result = self.screen_raw(self._rgb565(image), width, height)
|
||||
return {"clear_first": clear_first, "clear": clear_result, "write": write_result, "width": width, "height": height}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Unified local voice generation with explicit, non-interchangeable engines."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from reyna_cli.local_services_direct import KokoroDirectClient
|
||||
from reyna_cli.media_execution import generate_kokoro
|
||||
from reyna_cli.qwen_tts_direct import Qwen3TTSDirectClient
|
||||
|
||||
POCKET_SAMPLE_RATE = 24_000
|
||||
DEFAULT_POCKET_VOICE_STATE = Path.home() / "Downloads" / "jv_pocket.pt"
|
||||
|
||||
|
||||
class PocketTTSDirectClient:
|
||||
"""Kyutai Pocket TTS with the independently trained JV voice state."""
|
||||
|
||||
def __init__(self, *, voice_state: Optional[Path] = None) -> None:
|
||||
self.voice_state = Path(
|
||||
voice_state
|
||||
or os.environ.get("REYNA_POCKET_TTS_VOICE_STATE")
|
||||
or DEFAULT_POCKET_VOICE_STATE
|
||||
).expanduser()
|
||||
|
||||
def config_status(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"engine": "pocket",
|
||||
"runtime": "Kyutai Pocket TTS",
|
||||
"voice": "jv_pocket",
|
||||
"voice_state": str(self.voice_state),
|
||||
"voice_state_exists": self.voice_state.is_file(),
|
||||
"voice_state_bytes": self.voice_state.stat().st_size if self.voice_state.is_file() else None,
|
||||
"package_available": importlib.util.find_spec("pocket_tts") is not None,
|
||||
"sample_rate": POCKET_SAMPLE_RATE,
|
||||
"source": "direct",
|
||||
"offline": True,
|
||||
}
|
||||
|
||||
def validate_generate(self, text: str) -> Dict[str, Any]:
|
||||
if not text or not text.strip():
|
||||
raise ValueError("text required")
|
||||
if not self.voice_state.is_file():
|
||||
raise FileNotFoundError(f"Pocket voice state not found: {self.voice_state}")
|
||||
if importlib.util.find_spec("pocket_tts") is None:
|
||||
raise RuntimeError("Pocket TTS runtime is not installed; install pocket-tts before generation")
|
||||
return {"text": text.strip()[:8000], "engine": "pocket", "voice": "jv_pocket", "voice_state": str(self.voice_state)}
|
||||
|
||||
def generate(self, text: str, output: Path) -> Dict[str, Any]:
|
||||
self.validate_generate(text)
|
||||
import numpy as np
|
||||
import torch
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
destination = Path(output).expanduser()
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
model = TTSModel.load_model(temp=0.5, lsd_decode_steps=2)
|
||||
state = torch.load(self.voice_state, map_location="cpu", weights_only=True)
|
||||
audio = model.generate_audio(state, text.strip()[:8000])
|
||||
samples = audio.detach().cpu().numpy() if hasattr(audio, "detach") else np.asarray(audio)
|
||||
pcm = (np.clip(samples, -1.0, 1.0) * 32767).astype("<i2")
|
||||
with wave.open(str(destination), "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(POCKET_SAMPLE_RATE)
|
||||
wav.writeframes(pcm.tobytes())
|
||||
if not destination.is_file() or destination.stat().st_size <= 44:
|
||||
raise RuntimeError("Pocket TTS completed without creating audio")
|
||||
return {
|
||||
"ok": True,
|
||||
"engine": "pocket",
|
||||
"voice": "jv_pocket",
|
||||
"voice_state": str(self.voice_state),
|
||||
"filePath": str(destination),
|
||||
"sampleRate": POCKET_SAMPLE_RATE,
|
||||
"bytes": destination.stat().st_size,
|
||||
"source": "direct",
|
||||
}
|
||||
|
||||
|
||||
class UnifiedVoiceClient:
|
||||
"""A small dispatcher; engine identifiers are intentionally explicit."""
|
||||
|
||||
ENGINES = ("kokoro", "pocket", "qwen3-tts")
|
||||
|
||||
def __init__(self, *, pocket_voice_state: Optional[Path] = None) -> None:
|
||||
self.pocket = PocketTTSDirectClient(voice_state=pocket_voice_state)
|
||||
|
||||
def config_status(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"source": "direct",
|
||||
"engines": {
|
||||
"kokoro": KokoroDirectClient().config_status(),
|
||||
"pocket": self.pocket.config_status(),
|
||||
"qwen3-tts": Qwen3TTSDirectClient().config_status(),
|
||||
},
|
||||
}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
engine: str,
|
||||
text: str,
|
||||
output: Path,
|
||||
*,
|
||||
voice: Optional[str] = None,
|
||||
speed: float = 1.0,
|
||||
lang_code: Optional[str] = None,
|
||||
instruct: Optional[str] = None,
|
||||
temperature: float = 0.3,
|
||||
) -> Dict[str, Any]:
|
||||
engine = engine.strip().lower()
|
||||
if engine not in self.ENGINES:
|
||||
raise ValueError(f"engine must be one of: {', '.join(self.ENGINES)}")
|
||||
if engine == "kokoro":
|
||||
return generate_kokoro(text, output, voice=voice, speed=speed, lang_code=lang_code)
|
||||
if engine == "pocket":
|
||||
if voice not in (None, "", "jv_pocket"):
|
||||
raise ValueError("Pocket currently supports only the jv_pocket voice state")
|
||||
return self.pocket.generate(text, output)
|
||||
return Qwen3TTSDirectClient(instruct=instruct, temperature=temperature).generate(text, output)
|
||||
@@ -0,0 +1,774 @@
|
||||
"""Tests for Reyna CLI.app bundle builder — deterministic layout, Xcode-owned signing, fail-closed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _mock_proc(rc=0, stdout="", stderr=""):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Bundle layout & deterministic plist
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_bundle_paths_deterministic_repo_local():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
assert ab.BUNDLE_IDENTIFIER == "com.reyna.cli.privacy-host"
|
||||
assert ab.APP_BUNDLE_NAME == "Reyna CLI.app"
|
||||
assert ab.APP_EXECUTABLE_NAME == "ReynaCLIHost"
|
||||
|
||||
bundle = ab.app_bundle_path()
|
||||
assert "dist" in str(bundle)
|
||||
assert "Reyna CLI.app" in str(bundle)
|
||||
assert bundle.name == "Reyna CLI.app"
|
||||
assert bundle.parent.name == "dist"
|
||||
assert bundle.parent.parent.name == "ReynaCLIHost"
|
||||
|
||||
|
||||
def test_build_app_bundle_info_plist_deterministic():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
d1 = ab.build_app_bundle_info_plist_dict()
|
||||
d2 = ab.build_app_bundle_info_plist_dict()
|
||||
|
||||
assert d1 == d2, "plist dict must be deterministic"
|
||||
assert d1["CFBundleIdentifier"] == "com.reyna.cli.privacy-host"
|
||||
assert d1["CFBundleExecutable"] == "ReynaCLIHost"
|
||||
assert d1["CFBundleName"] == "Reyna CLI"
|
||||
assert d1["CFBundleDisplayName"] == "Reyna CLI"
|
||||
assert d1["CFBundlePackageType"] == "APPL"
|
||||
assert "NSCalendarsFullAccessUsageDescription" in d1
|
||||
assert "NSContactsUsageDescription" in d1
|
||||
assert "NSRemindersFullAccessUsageDescription" in d1
|
||||
assert "NSAppleEventsUsageDescription" not in d1, "Notes deferred — AppleEvents must be forbidden"
|
||||
forbidden = [
|
||||
"NSRemindersUsageDescription",
|
||||
"NSAppleMusicUsageDescription",
|
||||
"NSNotesUsageDescription",
|
||||
"NSMailUsageDescription",
|
||||
"NSAppleEventsUsageDescription",
|
||||
]
|
||||
for k in forbidden:
|
||||
assert k not in d1, f"forbidden key {k} present"
|
||||
|
||||
data = plistlib.dumps(d1, sort_keys=True)
|
||||
loaded = plistlib.loads(data)
|
||||
assert loaded == d1
|
||||
|
||||
|
||||
def test_app_bundle_info_plist_allows_write_only_optional_but_not_forbidden():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
d = ab.build_app_bundle_info_plist_dict()
|
||||
assert "NSCalendarsFullAccessUsageDescription" in d
|
||||
assert "NSContactsUsageDescription" in d
|
||||
assert "NSRemindersFullAccessUsageDescription" in d
|
||||
assert "NSAppleEventsUsageDescription" not in d, "AppleEvents forbidden — Notes deferred"
|
||||
for key in d.keys():
|
||||
if "UsageDescription" in key and key.startswith("NS"):
|
||||
assert key in {
|
||||
"NSCalendarsFullAccessUsageDescription",
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription",
|
||||
"NSCalendarsUsageDescription",
|
||||
"NSContactsUsageDescription",
|
||||
"NSRemindersFullAccessUsageDescription",
|
||||
}, f"unexpected usage description {key}"
|
||||
|
||||
|
||||
def test_app_bundle_info_plist_no_contacts_reminders_notes_mail():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
d = ab.build_app_bundle_info_plist_dict()
|
||||
# Only Calendar, Contacts, Reminders allowed. Notes/Mail/AppleEvents must be rejected (Notes deferred)
|
||||
for bad in ["Notes", "Mail", "AppleEvents"]:
|
||||
for k in d.keys():
|
||||
if bad.lower() in k.lower() and "UsageDescription" in k:
|
||||
raise AssertionError(f"bundle plist contains forbidden domain {bad} via {k}")
|
||||
for k in d.keys():
|
||||
low = k.lower()
|
||||
if "notesusage" in low or "mailusage" in low or "appleeventsusage" in low:
|
||||
raise AssertionError(f"forbidden domain key {k}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Xcode-owned signing – no manual codesign --sign
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_xcodebuild_command_static_contract():
|
||||
from reyna_cli import app_bundle as ab
|
||||
import tempfile
|
||||
|
||||
repo_root = Path(tempfile.mkdtemp()) / "repo"
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "custom" / "DerivedData"
|
||||
|
||||
cmd = ab.build_xcodebuild_command(repo_root=repo_root, derived_data_path=derived, disable_code_signing=False)
|
||||
assert isinstance(cmd, list)
|
||||
assert cmd[0] == "xcodebuild"
|
||||
# No shell
|
||||
assert all(isinstance(x, str) for x in cmd)
|
||||
# Must reference project, scheme, configuration, derivedDataPath, build verb
|
||||
assert "-project" in cmd
|
||||
proj_idx = cmd.index("-project")
|
||||
assert str(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj") == cmd[proj_idx + 1]
|
||||
assert "-scheme" in cmd
|
||||
assert "Reyna CLI" in cmd
|
||||
assert "-target" not in cmd, "must not use -target when using -derivedDataPath (RC 64)"
|
||||
assert "-configuration" in cmd
|
||||
assert "Release" in cmd
|
||||
assert "-derivedDataPath" in cmd
|
||||
dd_idx = cmd.index("-derivedDataPath")
|
||||
assert cmd[dd_idx + 1] == str(derived)
|
||||
assert "build" in cmd
|
||||
# Must NOT contain manual codesign signing command
|
||||
assert "--sign" not in cmd
|
||||
assert "CODE_SIGNING_ALLOWED=NO" not in cmd
|
||||
|
||||
cmd_unsigned = ab.build_xcodebuild_command(
|
||||
repo_root=repo_root, derived_data_path=derived, disable_code_signing=True
|
||||
)
|
||||
assert "CODE_SIGNING_ALLOWED=NO" in cmd_unsigned
|
||||
assert "--sign" not in cmd_unsigned
|
||||
|
||||
|
||||
def test_build_xcodebuild_command_no_manual_codesign_in_source():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src = Path(ab.__file__).read_text()
|
||||
# Source must NOT contain codesign --sign manual invocation (Xcode owns signing)
|
||||
# Allow comments about codesign --verify but not --sign as command construction
|
||||
lines = [l for l in src.splitlines() if "codesign" in l.lower() and "--sign" in l]
|
||||
# Only allowed if inside comment about not doing manual sign, not as actual command list
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"):
|
||||
continue
|
||||
# If we ever build ["codesign", "--force", ... "--sign"] that would be violation
|
||||
# Our module only uses codesign --verify and -dv for validation
|
||||
if '"codesign"' in line or "'codesign'" in line or '["codesign"' in line:
|
||||
assert "--sign" not in line or "verify" in line.lower(), f"manual codesign --sign found: {line}"
|
||||
# Strong check: no list containing both codesign and --sign for forced signing
|
||||
assert '["codesign", "--force"' not in src, "manual codesign --sign forbidden; Xcode owns signing"
|
||||
assert src.count('"--sign"') == 0 or 'codesign' not in src.split('"--sign"')[0][-200:].lower() or True
|
||||
# Final guard: validate no manual signing identity handling that invokes codesign --sign
|
||||
# Searching for pattern codesign.*--sign in code (not in comments) — we already checked above
|
||||
|
||||
|
||||
def test_app_bundle_no_manual_codesign_sign_invocation():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src = Path(ab.__file__).read_text()
|
||||
# Must not have ["codesign", "--force", "--options", "runtime", "--timestamp", "--sign", identity
|
||||
# The old builder used this; new builder must not
|
||||
assert "codesign" in src.lower() # verify still allowed
|
||||
# Ensure we never build a codesign --sign command array
|
||||
forbidden_snippets = [
|
||||
'"--force",\n "--options",\n "runtime"',
|
||||
'sign_cmd = [',
|
||||
]
|
||||
for snippet in forbidden_snippets:
|
||||
if snippet in src:
|
||||
# If present, ensure it's not constructing --sign command
|
||||
ctx = src[src.index(snippet) - 200 : src.index(snippet) + 400] if snippet in src else ""
|
||||
assert "--sign" not in ctx or "verify" in ctx.lower(), f"found manual sign cmd near {snippet}: {ctx[:500]}"
|
||||
|
||||
|
||||
def test_build_app_bundle_uses_xcodebuild_and_copies_product(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
# Create required xcodeproj dir and info plist source
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj" / "project.pbxproj").write_text("// dummy")
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text('<?xml version="1.0"?><plist><dict></dict></plist>')
|
||||
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"fakebinarycontent")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
def __init__(self, rc=0, stdout="", stderr=""):
|
||||
self.returncode = rc
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
assert isinstance(args, list), "must use arg array"
|
||||
calls.append(list(args))
|
||||
if args and args[0] == "xcodebuild":
|
||||
# simulate successful build - ensure product already exists
|
||||
return Proc(rc=0, stdout="BUILD SUCCEEDED", stderr="")
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return Proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return Proc(rc=0, stdout="", stderr="TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n")
|
||||
return Proc(rc=0)
|
||||
|
||||
result = ab.build_app_bundle(repo_root=repo_root, runner=runner, disable_code_signing=False)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert "xcodebuild" in str(result.get("build_command", [])).lower() or any(c[0] == "xcodebuild" for c in calls)
|
||||
# Ensure xcodebuild invocation used safe arg array with project/scheme/derivedDataPath
|
||||
xb_calls = [c for c in calls if c and c[0] == "xcodebuild"]
|
||||
assert len(xb_calls) == 1
|
||||
xb = xb_calls[0]
|
||||
assert "-project" in xb
|
||||
assert "-scheme" in xb
|
||||
assert "-target" not in xb
|
||||
assert "Reyna CLI" in xb
|
||||
assert "-derivedDataPath" in xb
|
||||
assert "build" in xb
|
||||
assert "--sign" not in xb
|
||||
# No manual codesign --sign
|
||||
sign_calls = [c for c in calls if c[0] == "codesign" and "--sign" in c]
|
||||
assert len(sign_calls) == 0, f"manual codesign --sign must not occur, got {sign_calls}"
|
||||
|
||||
# Product copied to dist
|
||||
dist_app = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
assert dist_app.exists()
|
||||
assert (dist_app / "Contents" / "MacOS" / "ReynaCLIHost").exists()
|
||||
|
||||
|
||||
def test_build_app_bundle_fails_if_xcodebuild_fails(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=1, stderr="BUILD FAILED")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.build_app_bundle(repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is False
|
||||
assert "xcodebuild" in res["error"].lower()
|
||||
|
||||
|
||||
def test_build_app_bundle_fails_if_product_missing_after_build(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.build_app_bundle(repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is False
|
||||
assert "product not found" in res["error"].lower()
|
||||
|
||||
|
||||
def test_build_app_bundle_unsigned_mode_allows_validation_failure(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="code object is not signed at all")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="Signature=adhoc\nTeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.build_app_bundle(repo_root=repo_root, runner=runner, disable_code_signing=True)
|
||||
# Unsigned build should succeed even if validation fails (for testing)
|
||||
assert res["ok"] is True
|
||||
assert res.get("unsigned_build") is True
|
||||
# But validation indicates not verified
|
||||
assert res["validation"]["signature_verified"] is False
|
||||
|
||||
|
||||
def test_build_app_bundle_validation_detects_ad_hoc_signed(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stdout="", stderr="Signature=adhoc\nTeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.build_app_bundle(repo_root=repo_root, runner=runner, disable_code_signing=False)
|
||||
assert res["ok"] is False
|
||||
assert "ad-hoc" in (res.get("error", "") + str(res.get("validation", {}))).lower()
|
||||
|
||||
|
||||
def test_build_app_bundle_verify_failure_fail_closed(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="code failed to satisfy")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.build_app_bundle(repo_root=repo_root, runner=runner, disable_code_signing=False)
|
||||
assert res["ok"] is False
|
||||
assert "verify" in res["error"].lower() or "validation" in res["error"].lower()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Validation – same as before, plus xcodeproj contract
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_app_bundle_layout_and_signature(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
contents = bundle / "Contents"
|
||||
macos = contents / "MacOS"
|
||||
macos.mkdir(parents=True)
|
||||
exe = macos / "ReynaCLIHost"
|
||||
exe.write_bytes(b"binary")
|
||||
exe.chmod(0o755)
|
||||
|
||||
plist_dict = ab.build_app_bundle_info_plist_dict()
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(plist_dict, f)
|
||||
|
||||
def runner_ok(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="Executable=...\nIdentifier=com.reyna.cli.privacy-host\nFormat=app bundle\nAuthority=Apple Development: Foo (TEAM123)\nTeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle(repo_root=repo_root, runner=runner_ok)
|
||||
assert res["ok"] is True
|
||||
assert res["bundle_exists"] is True
|
||||
assert res["executable_exists"] is True
|
||||
assert res["info_plist_exists"] is True
|
||||
assert res["bundle_identifier"] == "com.reyna.cli.privacy-host"
|
||||
assert res["bundle_identifier_matches"] is True
|
||||
assert res["signature_verified"] is True
|
||||
assert res["is_ad_hoc"] is False
|
||||
|
||||
|
||||
def test_validate_app_bundle_accepts_only_calendar_usage(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
contents = bundle / "Contents"
|
||||
macos = contents / "MacOS"
|
||||
macos.mkdir(parents=True)
|
||||
(macos / "ReynaCLIHost").write_bytes(b"x")
|
||||
plist_dict = ab.build_app_bundle_info_plist_dict()
|
||||
plist_dict["NSRemindersUsageDescription"] = "Should not be allowed"
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(plist_dict, f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle(repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is False
|
||||
assert any("reminders" in e.lower() or "forbidden" in e.lower() or "unexpected" in e.lower() for e in res["errors"])
|
||||
|
||||
|
||||
def test_validate_app_bundle_fails_if_ad_hoc(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
contents = bundle / "Contents"
|
||||
macos = contents / "MacOS"
|
||||
macos.mkdir(parents=True)
|
||||
(macos / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="Signature=adhoc\nTeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle(repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is False
|
||||
assert res["is_ad_hoc"] is True
|
||||
assert res["signature_verified"] is False
|
||||
|
||||
|
||||
def test_validate_app_bundle_fails_if_verify_fails(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="main executable failed strict validation")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle(repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is False
|
||||
assert res["signature_verified"] is False
|
||||
|
||||
|
||||
def test_xcodeproject_static_contract(tmp_path):
|
||||
"""Verify xcodeproject and Info.plist static contract exist."""
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = Path(ab.__file__).resolve().parents[2]
|
||||
proj = repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj"
|
||||
assert proj.exists(), f"xcodeproj missing at {proj}"
|
||||
src = proj.read_text()
|
||||
assert "com.reyna.cli.privacy-host" in src
|
||||
assert "Reyna CLI" in src
|
||||
assert "CODE_SIGN_STYLE = Automatic" in src
|
||||
|
||||
info_src = repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist"
|
||||
assert info_src.exists()
|
||||
with open(info_src, "rb") as f:
|
||||
d = plistlib.load(f)
|
||||
assert d["CFBundleIdentifier"] == "com.reyna.cli.privacy-host"
|
||||
assert d["CFBundleExecutable"] == "ReynaCLIHost"
|
||||
|
||||
|
||||
def test_build_app_bundle_no_secret_logging(tmp_path):
|
||||
"""Result must not contain secret identity in clear."""
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
secret_identity = "Apple Development: Very Secret Name (TEAM999)"
|
||||
res = ab.build_app_bundle(signing_identity=secret_identity, repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is True
|
||||
res_str = str(res)
|
||||
# secret team must not leak (Automatic Signing means we don't use identity at all)
|
||||
assert "TEAM999" not in res_str
|
||||
assert secret_identity not in res_str
|
||||
|
||||
|
||||
def test_lifecycle_refuses_invalid_unverified_bundle(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
sock = tmp_path / "priv" / "reyna-cli.sock"
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
logd = tmp_path / "Logs"
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="verify failed")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
if args[0] == "launchctl":
|
||||
return _mock_proc(rc=0)
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
result = ph_mod.install_privacy_host_service(
|
||||
runner=fake_runner, uid=501, repo_root=repo_root, socket_path=sock, plist_path=plist, log_dir=logd, signing_identity="Test ID"
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "verify" in result["error"].lower() or "validation" in result["error"].lower()
|
||||
|
||||
|
||||
def test_lifecycle_start_refuses_unverified_bundle(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / "native" / "ReynaCLIHost").mkdir(parents=True)
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist.parent.mkdir(parents=True)
|
||||
import plistlib as _plist
|
||||
|
||||
plist.write_bytes(_plist.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="verify failed")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="Signature=adhoc\nTeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
_plist.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
res = ph_mod.start_privacy_host_service(runner=fake_runner, uid=501, plist_path=plist, repo_root=repo_root)
|
||||
assert res["ok"] is False
|
||||
assert "bundle" in res["error"].lower()
|
||||
|
||||
|
||||
def test_status_outputs_app_bundle_path_and_verification_state(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod, app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
(repo_root / "native" / "ReynaCLIHost").mkdir(parents=True)
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
import plistlib
|
||||
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
sock = tmp_path / "sock" / "reyna-cli.sock"
|
||||
plist_path = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist_path.parent.mkdir(parents=True)
|
||||
import plistlib
|
||||
|
||||
plist_path.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = "pid = 1234\n"
|
||||
stderr = ""
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if isinstance(args, list) and args and args[0] == "codesign" and "--verify" in args:
|
||||
return Proc()
|
||||
if isinstance(args, list) and args[:2] == ["codesign", "-dv"]:
|
||||
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n"
|
||||
|
||||
return P()
|
||||
if isinstance(args, list) and args[0] == "launchctl":
|
||||
return Proc()
|
||||
return Proc()
|
||||
|
||||
status = ph_mod.privacy_host_service_status(
|
||||
runner=runner, uid=501, plist_path_override=plist_path, socket_path_override=sock, repo_root_override=repo_root
|
||||
)
|
||||
assert status["ok"] is True
|
||||
assert "app_bundle_path" in status
|
||||
assert "bundle_identifier" in status
|
||||
assert status["bundle_identifier"] == "com.reyna.cli.privacy-host"
|
||||
assert status["bundle_identifier_expected"] == "com.reyna.cli.privacy-host"
|
||||
assert "signature_verified" in status
|
||||
assert "bundle_exists" in status
|
||||
assert status["app_bundle_path_expected"] == str(bundle)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Contacts migration readiness – Xcode linkage + deterministic nil date
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_xcode_contacts_source_files_and_framework_linked():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = Path(ab.__file__).resolve().parents[2]
|
||||
proj = repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj"
|
||||
assert proj.exists()
|
||||
src = proj.read_text()
|
||||
|
||||
# Contacts source files must be in project
|
||||
assert "ContactsProvider.swift" in src, "ContactsProvider.swift missing from pbxproj"
|
||||
assert "ContactsAuthorizationProvider.swift" in src, "ContactsAuthorizationProvider.swift missing"
|
||||
|
||||
# Must be in Sources build phase
|
||||
assert "ContactsProvider.swift in Sources" in src
|
||||
assert "ContactsAuthorizationProvider.swift in Sources" in src
|
||||
|
||||
# Must be in ReynaCLIHostCore group
|
||||
# Find the core group and check its children include both
|
||||
assert "ReynaCLIHostCore" in src
|
||||
|
||||
# Framework must be linked
|
||||
assert "Contacts.framework" in src, "Contacts.framework missing from pbxproj"
|
||||
assert "Contacts.framework in Frameworks" in src, "Contacts.framework not in Frameworks phase"
|
||||
|
||||
# Also EventKit still present
|
||||
assert "EventKit.framework" in src
|
||||
|
||||
|
||||
def test_package_swift_links_contacts_framework():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = Path(ab.__file__).resolve().parents[2]
|
||||
pkg = repo_root / "native" / "ReynaCLIHost" / "Package.swift"
|
||||
assert pkg.exists()
|
||||
content = pkg.read_text()
|
||||
assert "Contacts" in content
|
||||
assert 'linkedFramework("Contacts")' in content
|
||||
# Should still link EventKit
|
||||
assert 'linkedFramework("EventKit")' in content
|
||||
|
||||
|
||||
def test_contacts_provider_nil_modification_date_deterministic():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = Path(ab.__file__).resolve().parents[2]
|
||||
provider = repo_root / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "ContactsProvider.swift"
|
||||
assert provider.exists()
|
||||
src = provider.read_text()
|
||||
# Must not use iso.string(from: Date()) as fallback – nondeterministic
|
||||
# Count occurrences that use current Date as fallback
|
||||
assert "iso.string(from: Date())" not in src, "ContactsProvider must not fallback to current Date() – nondeterministic"
|
||||
# Ensure the replacement is deterministic (empty string)
|
||||
# Both search and read providers should have deterministic fallback
|
||||
assert '?? ""' in src or 'modifiedStr = ""' in src or "= \"\"" in src
|
||||
|
||||
|
||||
def test_xcode_info_plist_and_generated_plist_alignment_calendar_plus_contacts():
|
||||
from reyna_cli import app_bundle as ab
|
||||
import plistlib
|
||||
|
||||
repo_root = Path(ab.__file__).resolve().parents[2]
|
||||
xcode_plist = repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist"
|
||||
assert xcode_plist.exists()
|
||||
with open(xcode_plist, "rb") as f:
|
||||
xcode_d = plistlib.load(f)
|
||||
|
||||
gen_d = ab.build_app_bundle_info_plist_dict()
|
||||
|
||||
# Both must have Calendar, Contacts, Reminders usage descriptions
|
||||
for key in ["NSCalendarsFullAccessUsageDescription", "NSContactsUsageDescription", "NSRemindersFullAccessUsageDescription"]:
|
||||
assert key in xcode_d, f"Xcode Info.plist missing {key}"
|
||||
assert key in gen_d, f"generated plist missing {key}"
|
||||
|
||||
# Bundle identity alignment
|
||||
assert xcode_d["CFBundleIdentifier"] == gen_d["CFBundleIdentifier"] == "com.reyna.cli.privacy-host"
|
||||
assert xcode_d["CFBundleExecutable"] == gen_d["CFBundleExecutable"] == "ReynaCLIHost"
|
||||
|
||||
# Security: no Notes/Mail/AppleEvents in either (Notes deferred)
|
||||
for d, label in [(xcode_d, "Xcode"), (gen_d, "generated")]:
|
||||
for bad in ["Notes", "Mail", "AppleEvents"]:
|
||||
for k in d.keys():
|
||||
if bad.lower() in k.lower() and "UsageDescription" in k:
|
||||
raise AssertionError(f"{label} plist contains forbidden domain {bad} via {k}")
|
||||
for k in d.keys():
|
||||
low = k.lower()
|
||||
if "notesusage" in low or "mailusage" in low or "appleeventsusage" in low:
|
||||
raise AssertionError(f"{label} plist contains forbidden domain via {k}")
|
||||
|
||||
# Allowed set only: Calendar, Contacts, Reminders (Notes deferred, no AppleEvents)
|
||||
allowed = {
|
||||
"NSCalendarsFullAccessUsageDescription",
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription",
|
||||
"NSCalendarsUsageDescription",
|
||||
"NSContactsUsageDescription",
|
||||
"NSRemindersFullAccessUsageDescription",
|
||||
}
|
||||
for d, label in [(xcode_d, "Xcode"), (gen_d, "generated")]:
|
||||
for k in d.keys():
|
||||
if k.startswith("NS") and "UsageDescription" in k:
|
||||
assert k in allowed, f"{label} plist has unexpected usage key {k}"
|
||||
@@ -0,0 +1,74 @@
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from reyna_cli.apple_llm_client import AppleLLMClient, get_apple_llm_session, apple_llm_close, apple_llm_status
|
||||
|
||||
@pytest.fixture
|
||||
def mock_apple_llm():
|
||||
with patch("subprocess.Popen") as mock_popen, \
|
||||
patch("subprocess.run") as mock_run, \
|
||||
patch("pathlib.Path.write_text"), \
|
||||
patch("shutil.rmtree"):
|
||||
|
||||
# Mock the binary build
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
# Mock the process
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_proc.pid = 1234
|
||||
mock_proc.stdout.readline.side_effect = [
|
||||
'{"id": "0", "ok": true, "text": "Polished text", "ms": 100, "mode": "line"}\\n',
|
||||
'{"id": "1", "ok": true, "text": "Quick reply", "ms": 50, "mode": "quick_reply"}\\n',
|
||||
'{"id": "2", "ok": true, "text": "Chat response", "ms": 200, "mode": "chat"}\\n',
|
||||
'', # EOF
|
||||
]
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
client = AppleLLMClient()
|
||||
yield client
|
||||
|
||||
def test_apple_llm_lifecycle(mock_apple_llm):
|
||||
# Test status before start
|
||||
status = apple_llm_status()
|
||||
assert status["active"] is False
|
||||
|
||||
# Start and check status
|
||||
mock_apple_llm.start()
|
||||
status = apple_llm_status()
|
||||
# Note: apple_llm_status uses the global singleton, not the fixture instance.
|
||||
# For the purpose of these tests, we'll mock the global session if needed,
|
||||
# but let's focus on the Client logic first.
|
||||
|
||||
def test_apple_llm_call_success(mock_apple_llm):
|
||||
payload = {"mode": "line", "text": "Hello world"}
|
||||
mock_proc = MagicMock()
|
||||
mock_apple_llm.proc = mock_proc
|
||||
|
||||
def reply_on_write(_: str) -> None:
|
||||
event, result_box = mock_apple_llm.pending["0"]
|
||||
result_box["data"] = {"ok": True, "text": "Polished!"}
|
||||
event.set()
|
||||
|
||||
mock_proc.stdin.write.side_effect = reply_on_write
|
||||
with patch.object(mock_apple_llm, "start"):
|
||||
res = mock_apple_llm.call(payload)
|
||||
|
||||
assert res["ok"] is True
|
||||
assert res["text"] == "Polished!"
|
||||
|
||||
def test_apple_llm_timeout(mock_apple_llm):
|
||||
with patch("threading.Event.wait", return_value=False):
|
||||
res = mock_apple_llm.call({"mode": "line", "text": "test"}, timeout=0.1)
|
||||
assert res["ok"] is False
|
||||
assert "timeout" in res["error"]
|
||||
|
||||
def test_apple_llm_check_mock(mock_apple_llm):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout='{"ok": true, "available": true, "ping": "ok"}',
|
||||
returncode=0
|
||||
)
|
||||
res = mock_apple_llm.check()
|
||||
assert res["ok"] is True
|
||||
assert res["available"] is True
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests for explicit calendar-authorize operation – TDD fakes only, no live service."""
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_privacy_client_direct_op_uses_explicit_operation(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout):
|
||||
captured["timeout"] = timeout
|
||||
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "x", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "calendar.request_full_access", "status": "authorized"}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_calendar_request_full_access()
|
||||
assert captured["op"] == "calendar.request_full_access"
|
||||
assert captured["args"] == {}
|
||||
assert captured["timeout"] == 35
|
||||
assert result["ok"] is True
|
||||
assert result["source"] == "native_privacy_host"
|
||||
assert result["result"]["status"] == "authorized"
|
||||
|
||||
|
||||
def test_calendar_authorize_cli_no_generic_fallback(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_native():
|
||||
calls["count"] += 1
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"protocol_version": "1.0.0", "operation": "calendar.request_full_access", "status": "authorized"}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_request_full_access", fake_native)
|
||||
|
||||
# Ensure src does not use generic call / MCP fallback
|
||||
src = ph_mod.__file__
|
||||
import pathlib
|
||||
text = pathlib.Path(src).read_text()
|
||||
# The new function must call PrivacyClient directly with explicit op and not use generic 'call' helper referencing arbitrary operation arg
|
||||
# CLI command must import the explicit function, not PrivacyClient directly (checked via cli source)
|
||||
cli_text = pathlib.Path("src/reyna_cli/cli.py").read_text() if pathlib.Path("src/reyna_cli/cli.py").exists() else pathlib.Path(__file__).parents[1].joinpath("src/reyna_cli/cli.py").read_text()
|
||||
|
||||
res = runner.invoke(app, ["privacy-host", "calendar-authorize", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["result"]["status"] == "authorized"
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_calendar_authorize_cli_help_mentions_prompt():
|
||||
res = runner.invoke(app, ["privacy-host", "calendar-authorize", "--help"])
|
||||
assert res.exit_code == 0
|
||||
out = res.stdout.lower()
|
||||
# Help must make prompting clear
|
||||
assert "calendar" in out
|
||||
assert "permission" in out or "prompt" in out or "privacy" in out
|
||||
|
||||
|
||||
def test_calendar_authorize_failures_surface(monkeypatch):
|
||||
from reyna_cli.privacy_client import PrivacyClientError
|
||||
|
||||
def fake_fail():
|
||||
raise PrivacyClientError("privacy RPC returned ok=false: {'code': 'permission_denied'}")
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_request_full_access", fake_fail)
|
||||
|
||||
res = runner.invoke(app, ["privacy-host", "calendar-authorize", "--json"])
|
||||
assert res.exit_code != 0
|
||||
# payload should have ok:false
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is False
|
||||
|
||||
|
||||
def test_native_calendar_request_full_access_no_mcp_import():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
import pathlib
|
||||
src = pathlib.Path(ph_mod.__file__).read_text()
|
||||
# Ensure new function does not import MCP fallback
|
||||
# We locate function definition region
|
||||
# Simple guard: whole module still must not reference MCP fallback helpers
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "macmini_client" not in src
|
||||
# The specific new function should exist
|
||||
assert "def native_calendar_request_full_access" in src
|
||||
assert "calendar.request_full_access" in src
|
||||
|
||||
|
||||
def test_privacy_host_cli_has_calendar_authorize():
|
||||
res = runner.invoke(app, ["privacy-host", "--help"])
|
||||
assert res.exit_code == 0
|
||||
assert "calendar-authorize" in res.stdout
|
||||
# Ensure no generic 'call' command exposed
|
||||
assert "call" not in res.stdout.lower() or "calendar-authorize" in res.stdout
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Tests for calendar event list/create native wrappers and CLI routing – TDD, no live calls."""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_native_calendar_events_list_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "abc", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "calendar.events.list", "events": []}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
result = ph_mod.native_calendar_events_list(start="2026-01-01T00:00:00Z", end="2026-01-02T00:00:00Z", calendar="Home", limit=25)
|
||||
|
||||
assert captured["op"] == "calendar.events.list"
|
||||
assert captured["args"]["start"] == "2026-01-01T00:00:00Z"
|
||||
assert captured["args"]["end"] == "2026-01-02T00:00:00Z"
|
||||
assert captured["args"]["calendar"] == "Home"
|
||||
assert captured["args"]["limit"] == 25
|
||||
assert result["ok"] is True
|
||||
assert result["source"] == "native_privacy_host"
|
||||
|
||||
|
||||
def test_native_calendar_events_list_with_calendar_id(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["args"] = args
|
||||
return {"id": "abc", "ok": True, "result": {"events": []}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
ph_mod.native_calendar_events_list(start="2026-01-01T00:00:00Z", end="2026-01-02T00:00:00Z", calendar_id="stable-id-123", limit=50)
|
||||
|
||||
assert captured["args"]["calendar_id"] == "stable-id-123"
|
||||
assert "calendar" not in captured["args"] or captured["args"].get("calendar") is None
|
||||
|
||||
|
||||
def test_native_calendar_events_list_failure_surfaces(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli.privacy_client import PrivacyClientError
|
||||
|
||||
class FakeFail:
|
||||
def call(self, op, args):
|
||||
raise PrivacyClientError("privacy RPC returned ok=false: permission_required")
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeFail)
|
||||
|
||||
with pytest.raises(PrivacyClientError):
|
||||
ph_mod.native_calendar_events_list(start="2026-01-01T00:00:00Z", end="2026-01-02T00:00:00Z")
|
||||
|
||||
|
||||
def test_native_calendar_event_create_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "c", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "calendar.event.create", "event": {"id": "new"}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
result = ph_mod.native_calendar_event_create(
|
||||
title="Meeting",
|
||||
start="2026-01-01T10:00:00Z",
|
||||
end="2026-01-01T11:00:00Z",
|
||||
all_day=False,
|
||||
notes="bring docs",
|
||||
location="Room 1",
|
||||
calendar_id="cal-id-1",
|
||||
calendar=None,
|
||||
)
|
||||
|
||||
assert captured["op"] == "calendar.event.create"
|
||||
assert captured["args"]["title"] == "Meeting"
|
||||
assert captured["args"]["start"] == "2026-01-01T10:00:00Z"
|
||||
assert captured["args"]["end"] == "2026-01-01T11:00:00Z"
|
||||
assert captured["args"]["all_day"] is False
|
||||
assert captured["args"]["notes"] == "bring docs"
|
||||
assert captured["args"]["calendar_id"] == "cal-id-1"
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_native_calendar_event_create_with_calendar_title(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["args"] = args
|
||||
return {"id": "c", "ok": True, "result": {"event": {"id": "new"}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
ph_mod.native_calendar_event_create(title="T", start="2026-01-01T10:00:00Z", end="2026-01-01T11:00:00Z", calendar="Home")
|
||||
|
||||
assert captured["args"]["calendar"] == "Home"
|
||||
assert "calendar_id" not in captured["args"]
|
||||
|
||||
|
||||
def test_no_mcp_in_new_wrappers():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
# wrappers must still not contain MCP fallback
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "MCPClient" not in src
|
||||
|
||||
|
||||
def test_cli_events_uses_native_wrapper(monkeypatch):
|
||||
calls = {"count": 0, "args": None}
|
||||
|
||||
def fake_events(start, end, calendar_id=None, calendar=None, limit=50):
|
||||
calls["count"] += 1
|
||||
calls["args"] = {"start": start, "end": end, "calendar_id": calendar_id, "calendar": calendar, "limit": limit}
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"events": []}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_events_list", fake_events)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "events", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert calls["count"] == 1
|
||||
assert calls["args"]["start"] == "2026-01-01T00:00:00Z"
|
||||
|
||||
|
||||
def test_cli_events_with_calendar_title(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_events(start, end, calendar_id=None, calendar=None, limit=50):
|
||||
captured["calendar"] = calendar
|
||||
captured["calendar_id"] = calendar_id
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"events": []}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_events_list", fake_events)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "events", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z", "--calendar", "Home", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
assert captured["calendar"] == "Home"
|
||||
assert captured["calendar_id"] is None
|
||||
|
||||
|
||||
def test_cli_events_with_calendar_index_resolves_once(monkeypatch):
|
||||
# Track how many times native_calendar_list is called – must be exactly once for index compat
|
||||
list_calls = {"count": 0}
|
||||
events_calls = {"args": None}
|
||||
|
||||
def fake_list():
|
||||
list_calls["count"] += 1
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "native_privacy_host",
|
||||
"result": {
|
||||
"protocol_version": "1.0.0",
|
||||
"operation": "calendar.list",
|
||||
"calendars": [
|
||||
{"id": "id-0", "title": "Home", "source": "iCloud", "type": "caldav"},
|
||||
{"id": "id-1", "title": "Work", "source": "iCloud", "type": "caldav"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def fake_events(start, end, calendar_id=None, calendar=None, limit=50):
|
||||
events_calls["args"] = {"calendar_id": calendar_id, "calendar": calendar}
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"events": []}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_list)
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_events_list", fake_events)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "events", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z", "--calendar-index", "1", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
assert list_calls["count"] == 1, "must resolve calendar list exactly once"
|
||||
assert events_calls["args"]["calendar_id"] == "id-1"
|
||||
assert events_calls["args"]["calendar"] is None
|
||||
|
||||
|
||||
def test_cli_events_invalid_calendar_index_returns_error(monkeypatch):
|
||||
def fake_list():
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "native_privacy_host",
|
||||
"result": {
|
||||
"calendars": [
|
||||
{"id": "id-0", "title": "Home", "source": "iCloud", "type": "caldav"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_list)
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_events_list", lambda **kwargs: {"ok": True, "source": "native", "result": {}})
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "events", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z", "--calendar-index", "5", "--json"])
|
||||
assert result.exit_code != 0
|
||||
# fail() with json_output should emit ok:false payload
|
||||
assert "out of range" in result.stdout or "out of range" in result.stderr or "ok" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_cli_create_uses_native_wrapper(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_create(title, start, end, all_day=False, notes=None, location=None, calendar_id=None, calendar=None):
|
||||
captured["title"] = title
|
||||
captured["calendar"] = calendar
|
||||
captured["calendar_id"] = calendar_id
|
||||
captured["notes"] = notes
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"event": {"id": "new"}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_event_create", fake_create)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "create", "Meeting", "2026-01-01T10:00:00Z", "2026-01-01T11:00:00Z", "--calendar", "Home", "--notes", "bring docs", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
assert captured["title"] == "Meeting"
|
||||
assert captured["calendar"] == "Home"
|
||||
assert captured["notes"] == "bring docs"
|
||||
|
||||
|
||||
def test_cli_create_with_calendar_index(monkeypatch):
|
||||
list_calls = {"count": 0}
|
||||
create_calls = {}
|
||||
|
||||
def fake_list():
|
||||
list_calls["count"] += 1
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "native_privacy_host",
|
||||
"result": {"calendars": [{"id": "id-xyz", "title": "Home", "source": "iCloud", "type": "caldav"}]},
|
||||
}
|
||||
|
||||
def fake_create(title, start, end, all_day=False, notes=None, location=None, calendar_id=None, calendar=None):
|
||||
create_calls["calendar_id"] = calendar_id
|
||||
create_calls["calendar"] = calendar
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"event": {"id": "new"}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_list)
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_event_create", fake_create)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "create", "T", "2026-01-01T10:00:00Z", "2026-01-01T11:00:00Z", "--calendar-index", "0", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
assert list_calls["count"] == 1
|
||||
assert create_calls["calendar_id"] == "id-xyz"
|
||||
assert create_calls["calendar"] is None
|
||||
|
||||
|
||||
def test_cli_create_no_default_calendar_first_arbitrary(monkeypatch):
|
||||
# When no calendar specified, wrapper should receive None for both and then host will reject (no default)
|
||||
captured = {}
|
||||
|
||||
def fake_create(title, start, end, all_day=False, notes=None, location=None, calendar_id=None, calendar=None):
|
||||
captured["calendar_id"] = calendar_id
|
||||
captured["calendar"] = calendar
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"event": {"id": "new"}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_event_create", fake_create)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "create", "Title", "2026-01-01T10:00:00Z", "2026-01-01T11:00:00Z", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
assert captured["calendar_id"] is None
|
||||
assert captured["calendar"] is None
|
||||
|
||||
|
||||
def test_cli_events_no_mcp_tool_call(monkeypatch):
|
||||
# Prove no call_macmini_tool present in file after change
|
||||
from reyna_cli import cli as cli_mod
|
||||
|
||||
src = Path(cli_mod.__file__).read_text()
|
||||
# Find events function section – must not contain call_macmini_tool for calendar_list_events
|
||||
# Overall file still may have call_macmini_tool for contacts etc, but our two commands must not use it
|
||||
# So check that native wrappers are used
|
||||
assert "native_calendar_events_list" in src
|
||||
assert "native_calendar_event_create" in src
|
||||
# Ensure the old patterns are gone from those specific functions by checking surrounding lines
|
||||
# Simpler: assert the literal string call_macmini_tool("calendar_list_events" not present
|
||||
assert 'calendar_list_events' not in src or 'call_macmini_tool(\"calendar_list_events\"' not in src
|
||||
assert 'calendar_create_event' not in src or 'call_macmini_tool(\"calendar_create_event\"' not in src
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests for contacts native wrappers and CLI – TDD fakes only, no live Contacts access."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_native_contacts_search_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "abc", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "contacts.search", "contacts": [{"id": "1", "name": "Alice", "organization": "OrgA", "modifiedAt": "2026-01-01T00:00:00Z"}]}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
result = ph_mod.native_contacts_search(query="alice", limit=20)
|
||||
|
||||
assert captured["op"] == "contacts.search"
|
||||
assert captured["args"]["query"] == "alice"
|
||||
assert captured["args"]["limit"] == 20
|
||||
assert result["ok"] is True
|
||||
assert result["source"] == "native_privacy_host"
|
||||
assert len(result["result"]["contacts"]) == 1
|
||||
|
||||
|
||||
def test_native_contacts_search_no_query(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["args"] = args
|
||||
return {"id": "x", "ok": True, "result": {"contacts": []}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
ph_mod.native_contacts_search(query=None, limit=10)
|
||||
assert "query" not in captured["args"]
|
||||
|
||||
|
||||
def test_native_contacts_read_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "a", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "contacts.read", "contact": {"id": "1", "name": "Alice"}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_contacts_read(contact_id="1")
|
||||
assert captured["op"] == "contacts.read"
|
||||
assert captured["args"]["id"] == "1"
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_native_contacts_create_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "b", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "contacts.create", "created_contact": {"id": "new", "name": "Alice", "organization": ""}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_contacts_create(first_name="Alice", last_name="Smith", email="alice@example.com")
|
||||
assert captured["op"] == "contacts.create"
|
||||
assert captured["args"]["firstName"] == "Alice"
|
||||
assert captured["args"]["lastName"] == "Smith"
|
||||
assert captured["args"]["email"]["value"] == "alice@example.com"
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_native_contacts_request_access_explicit_op(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout):
|
||||
captured["timeout"] = timeout
|
||||
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "x", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "contacts.request_access", "status": "authorized"}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_contacts_request_access()
|
||||
assert captured["op"] == "contacts.request_access"
|
||||
assert captured["args"] == {}
|
||||
assert captured["timeout"] == 35
|
||||
assert result["result"]["status"] == "authorized"
|
||||
|
||||
|
||||
def test_native_contacts_no_mcp_import():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
import pathlib
|
||||
src = pathlib.Path(ph_mod.__file__).read_text()
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "macmini_client" not in src
|
||||
assert "def native_contacts_request_access" in src
|
||||
assert "contacts.request_access" in src
|
||||
assert "def native_contacts_search" in src
|
||||
assert "def native_contacts_read" in src
|
||||
assert "def native_contacts_create" in src
|
||||
|
||||
|
||||
def test_cli_contacts_search_uses_native(monkeypatch):
|
||||
def fake_search(query=None, limit=20):
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"contacts": [{"id": "1", "name": "Alice", "organization": "", "modifiedAt": "2026-01-01T00:00:00Z"}]}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_contacts_search", fake_search)
|
||||
res = runner.invoke(app, ["macmini", "contacts", "search", "--query", "alice", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_cli_contacts_read_uses_native(monkeypatch):
|
||||
def fake_read(contact_id):
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"contact": {"id": contact_id, "name": "Alice", "firstName": "Alice", "lastName": "", "organization": "", "jobTitle": "", "emails": [], "phones": [], "modifiedAt": "2026-01-01T00:00:00Z"}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_contacts_read", fake_read)
|
||||
res = runner.invoke(app, ["macmini", "contacts", "read", "abc-123", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_cli_contacts_create_uses_native(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_create(first_name=None, last_name=None, organization=None, job_title=None, note=None, email=None, phone=None):
|
||||
captured["first_name"] = first_name
|
||||
captured["email"] = email
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"created_contact": {"id": "new", "name": "Alice", "organization": ""}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_contacts_create", fake_create)
|
||||
res = runner.invoke(app, ["macmini", "contacts", "create", "--first-name", "Alice", "--email", "alice@example.com", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["first_name"] == "Alice"
|
||||
assert captured["email"] == "alice@example.com"
|
||||
|
||||
|
||||
def test_cli_contacts_no_mcp_tool_call_remaining():
|
||||
from reyna_cli import cli as cli_mod
|
||||
src = Path(cli_mod.__file__).read_text()
|
||||
assert "native_contacts_search" in src
|
||||
assert "native_contacts_read" in src
|
||||
assert "native_contacts_create" in src
|
||||
assert 'call_macmini_tool("contacts_search"' not in src
|
||||
assert 'call_macmini_tool("contacts_read"' not in src
|
||||
assert 'call_macmini_tool("contacts_create"' not in src
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from reyna_cli.gitea_direct import GiteaClient, GiteaCredentials, load_credentials, redact_sensitive
|
||||
|
||||
|
||||
def test_load_credentials_prefers_environment_without_exposing_token(monkeypatch):
|
||||
monkeypatch.setenv("REYNA_GITEA_TOKEN", "test-token-value")
|
||||
monkeypatch.setenv("REYNA_GITEA_URL", "https://git.example.test/")
|
||||
|
||||
credentials = load_credentials()
|
||||
|
||||
assert credentials.token == "test-token-value"
|
||||
assert credentials.source == "REYNA_GITEA_TOKEN"
|
||||
assert credentials.public_dict() == {
|
||||
"configured": True,
|
||||
"host": "git.example.test",
|
||||
"source": "REYNA_GITEA_TOKEN",
|
||||
}
|
||||
assert "token" not in credentials.public_dict()
|
||||
|
||||
|
||||
def test_load_credentials_reads_git_credentials_file(monkeypatch, tmp_path: Path):
|
||||
credentials_file = tmp_path / "credentials"
|
||||
credentials_file.write_text(
|
||||
"https://adolforeyna:stored-secret@git.reynafamily.com\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.delenv("REYNA_GITEA_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITEA_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GIT_CREDENTIALS_FILE", str(credentials_file))
|
||||
|
||||
credentials = load_credentials()
|
||||
|
||||
assert credentials.username == "adolforeyna"
|
||||
assert credentials.token == "stored-secret"
|
||||
assert credentials.source == "GIT_CREDENTIALS_FILE"
|
||||
assert "stored-secret" not in str(credentials.public_dict())
|
||||
|
||||
|
||||
def test_repo_response_is_redacted_and_requests_authenticated_endpoint():
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"full_name": "adolforeyna/reyna-cli",
|
||||
"permissions": {"push": True},
|
||||
"token": "response-secret",
|
||||
},
|
||||
)
|
||||
|
||||
client = GiteaClient(
|
||||
GiteaCredentials("adolforeyna", "request-secret", "test", "https://git.example.test"),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
result = client.repo("adolforeyna/reyna-cli")
|
||||
|
||||
assert requests[0].url.path == "/api/v1/repos/adolforeyna/reyna-cli"
|
||||
assert requests[0].headers["authorization"] == "token request-secret"
|
||||
assert result["permissions"]["push"] is True
|
||||
assert result["token"] == "[REDACTED]"
|
||||
assert "response-secret" not in str(result)
|
||||
|
||||
|
||||
def test_remote_credential_helper_can_make_a_safe_api_request(monkeypatch):
|
||||
credentials = GiteaCredentials("", "", "Pi 5 git credential helper", "https://git.example.test")
|
||||
assert credentials.public_dict()["configured"] is True
|
||||
client = GiteaClient(credentials)
|
||||
monkeypatch.setattr(client, "_remote_get", lambda path: {"full_name": "adolforeyna/reyna-cli"})
|
||||
|
||||
assert client.repo("adolforeyna/reyna-cli") == {"full_name": "adolforeyna/reyna-cli"}
|
||||
|
||||
|
||||
def test_redact_sensitive_handles_nested_api_values():
|
||||
assert redact_sensitive(
|
||||
{"password": "one", "nested": [{"access_token": "two"}], "name": "safe"}
|
||||
) == {"password": "[REDACTED]", "nested": [{"access_token": "[REDACTED]"}], "name": "safe"}
|
||||
@@ -0,0 +1,142 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_media_execution_local_tts_delegates(monkeypatch, tmp_path):
|
||||
from reyna_cli import media_execution
|
||||
|
||||
target = tmp_path / "speech.wav"
|
||||
|
||||
def fake_synthesize(text, output, **kwargs):
|
||||
output.write_bytes(b"RIFFfake")
|
||||
return output
|
||||
|
||||
monkeypatch.setattr(media_execution, "synthesize_wav", fake_synthesize)
|
||||
result = media_execution.generate_local_tts("hello", target, voice="Alex")
|
||||
assert result["ok"] is True
|
||||
assert result["filePath"] == str(target)
|
||||
assert target.read_bytes() == b"RIFFfake"
|
||||
|
||||
|
||||
def test_media_execution_kokoro_writes_returned_file(monkeypatch, tmp_path):
|
||||
from reyna_cli import media_execution
|
||||
|
||||
source = tmp_path / "source.wav"
|
||||
source.write_bytes(b"wav")
|
||||
target = tmp_path / "target.wav"
|
||||
monkeypatch.setattr(media_execution, "_json_request", lambda *args, **kwargs: {"filePath": str(source), "voice": "af_heart"})
|
||||
result = media_execution.generate_kokoro("hello", target, url="http://127.0.0.1:7332")
|
||||
assert result["ok"] is True
|
||||
assert target.read_bytes() == b"wav"
|
||||
|
||||
|
||||
def test_media_execution_gemini_requires_key(monkeypatch, tmp_path):
|
||||
from reyna_cli import media_execution
|
||||
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
try:
|
||||
media_execution.generate_gemini_image("a tree", tmp_path / "tree.png")
|
||||
except RuntimeError as exc:
|
||||
assert "GEMINI_API_KEY" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing Gemini key must fail before network")
|
||||
|
||||
|
||||
def test_media_execution_gemini_decodes_image(monkeypatch, tmp_path):
|
||||
from reyna_cli import media_execution
|
||||
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-only")
|
||||
monkeypatch.setattr(media_execution, "_json_request", lambda *args, **kwargs: {"id": "i1", "output_image": {"data": "aGVsbG8=", "mime_type": "image/png"}})
|
||||
target = tmp_path / "tree.png"
|
||||
result = media_execution.generate_gemini_image("a tree", target)
|
||||
assert result["path"] == str(target)
|
||||
assert target.read_bytes() == b"hello"
|
||||
|
||||
|
||||
def test_cli_exposes_execution_commands():
|
||||
for args in [
|
||||
["local-services", "speech", "--help"],
|
||||
["local-services", "kokoro", "--help"],
|
||||
["local-services", "voicebox", "--help"],
|
||||
["local-services", "image", "--help"],
|
||||
]:
|
||||
result = runner.invoke(app, args)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "generate" in result.stdout
|
||||
|
||||
|
||||
def test_cli_image_rejects_unknown_engine():
|
||||
result = runner.invoke(app, ["local-services", "image", "generate", "tree", "--engine", "unknown", "--json"])
|
||||
assert result.exit_code == 1
|
||||
assert "codex or gemini" in result.stdout
|
||||
assert json.loads(result.stdout)["ok"] is False
|
||||
|
||||
|
||||
def test_speech_file_execution_uses_cached_binary(monkeypatch, tmp_path):
|
||||
from reyna_cli import speech_execution
|
||||
|
||||
audio = tmp_path / "sample.wav"
|
||||
audio.write_bytes(b"wav")
|
||||
binary = tmp_path / "transcriber"
|
||||
binary.write_bytes(b"binary")
|
||||
|
||||
monkeypatch.setattr(speech_execution, "_ensure_binary", lambda: binary)
|
||||
monkeypatch.setattr(
|
||||
speech_execution.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: type("Result", (), {"returncode": 0, "stdout": '{"ok":true,"transcript":"hello"}', "stderr": ""})(),
|
||||
)
|
||||
result = speech_execution.transcribe_file(audio, locale="en-US")
|
||||
assert result["transcript"] == "hello"
|
||||
assert result["source"] == "reyna_cli_direct"
|
||||
|
||||
|
||||
def test_speech_file_execution_rejects_missing_audio(tmp_path):
|
||||
from reyna_cli.speech_execution import SpeechExecutionError, transcribe_file
|
||||
|
||||
try:
|
||||
transcribe_file(tmp_path / "missing.wav")
|
||||
except SpeechExecutionError as exc:
|
||||
assert "not found" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing audio must fail before compilation")
|
||||
|
||||
|
||||
def test_live_session_frames_audio_and_reads_event(monkeypatch, tmp_path):
|
||||
from reyna_cli import speech_live
|
||||
|
||||
class FakeStdin:
|
||||
def __init__(self):
|
||||
self.data = bytearray()
|
||||
def write(self, value):
|
||||
self.data.extend(value)
|
||||
def flush(self):
|
||||
pass
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self):
|
||||
self.stdin = FakeStdin()
|
||||
self.stdout = []
|
||||
def poll(self):
|
||||
return None
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
def kill(self):
|
||||
pass
|
||||
|
||||
fake = FakeProcess()
|
||||
monkeypatch.setattr(speech_live, "_ensure_binary", lambda: tmp_path / "live")
|
||||
monkeypatch.setattr(speech_live.subprocess, "Popen", lambda *args, **kwargs: fake)
|
||||
session = speech_live.SpeechLiveSession("en-US")
|
||||
session.events.put({"ok": True, "event": "final", "text": "hello"})
|
||||
result = session.transcribe_chunk(b"wav")
|
||||
assert result["text"] == "hello"
|
||||
assert bytes(fake.stdin.data[:4]) == b"\x00\x00\x00\x03"
|
||||
session.close()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Regression tests for direct mutable Apple Notes CLI support.
|
||||
|
||||
Notes are deliberately implemented in Python through a fixed JXA script, not
|
||||
in the stable Swift privacy host. That preserves the signed launcher binary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
from reyna_cli.notes_direct import create_note, delete_note, list_notes, read_note, update_note
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _success_runner(expected_payload, result):
|
||||
def run(argv, **kwargs):
|
||||
assert argv[:5] == ["/usr/bin/osascript", "-l", "JavaScript", "-e", argv[4]]
|
||||
assert argv[5] == "--"
|
||||
assert json.loads(argv[6]) == expected_payload
|
||||
return SimpleNamespace(returncode=0, stdout=json.dumps(result), stderr="")
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def test_list_notes_uses_fixed_jxa_and_json_argument():
|
||||
notes = list_notes(
|
||||
query="family",
|
||||
folder="Adolfo",
|
||||
include_preview=True,
|
||||
limit=7,
|
||||
runner=_success_runner(
|
||||
{"query": "family", "folder": "Adolfo", "includePreview": True, "limit": 7},
|
||||
[{"id": "n1", "title": "Family", "folder": "Adolfo"}],
|
||||
),
|
||||
)
|
||||
assert notes == [{"id": "n1", "title": "Family", "folder": "Adolfo"}]
|
||||
|
||||
|
||||
def test_read_and_create_notes_use_json_arguments_without_script_interpolation():
|
||||
note = read_note(
|
||||
"x-coredata://note-1",
|
||||
runner=_success_runner(
|
||||
{"id": "x-coredata://note-1"},
|
||||
{"id": "x-coredata://note-1", "title": "Existing", "plaintext": "body"},
|
||||
),
|
||||
)
|
||||
assert note["title"] == "Existing"
|
||||
|
||||
created = create_note(
|
||||
"Test <title>",
|
||||
"One & two\nthree",
|
||||
folder="Adolfo",
|
||||
runner=_success_runner(
|
||||
{"title": "Test <title>", "body": "One & two\nthree", "folder": "Adolfo"},
|
||||
{"id": "new-1", "title": "Test <title>", "folder": "Adolfo"},
|
||||
),
|
||||
)
|
||||
assert created["id"] == "new-1"
|
||||
|
||||
|
||||
def test_update_and_delete_notes_use_fixed_jxa_and_json_arguments():
|
||||
updated = update_note(
|
||||
"x-coredata://note-1",
|
||||
title="Updated",
|
||||
body="New body",
|
||||
runner=_success_runner(
|
||||
{"id": "x-coredata://note-1", "title": "Updated", "body": "New body"},
|
||||
{"id": "x-coredata://note-1", "title": "Updated"},
|
||||
),
|
||||
)
|
||||
assert updated["title"] == "Updated"
|
||||
|
||||
deleted = delete_note(
|
||||
"x-coredata://note-1",
|
||||
runner=_success_runner({"id": "x-coredata://note-1"}, {"id": "x-coredata://note-1", "deleted": True}),
|
||||
)
|
||||
assert deleted["deleted"] is True
|
||||
|
||||
|
||||
def test_notes_cli_is_available_at_top_level_and_macmini_alias(monkeypatch):
|
||||
monkeypatch.setattr("reyna_cli.cli.list_notes", lambda **_: [{"id": "n1", "title": "Test"}])
|
||||
|
||||
top_level = runner.invoke(app, ["notes", "list", "--json"])
|
||||
assert top_level.exit_code == 0
|
||||
assert json.loads(top_level.stdout)["notes"][0]["id"] == "n1"
|
||||
|
||||
compatibility_alias = runner.invoke(app, ["macmini", "notes", "list", "--json"])
|
||||
assert compatibility_alias.exit_code == 0
|
||||
assert json.loads(compatibility_alias.stdout)["notes"][0]["title"] == "Test"
|
||||
@@ -0,0 +1,540 @@
|
||||
"""TDD for secure explicit prebuilt signed app install bridge — no DerivedData scan, no secret leak."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _mock_proc(rc=0, stdout="", stderr=""):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
def _make_fake_app_bundle(base: Path, bundle_name: str = "Reyna CLI.app", bundle_id: str = "com.reyna.cli.privacy-host"):
|
||||
"""Create a minimal .app bundle directory with Info.plist and executable."""
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
bundle = base / bundle_name
|
||||
contents = bundle / "Contents"
|
||||
macos = contents / "MacOS"
|
||||
macos.mkdir(parents=True, exist_ok=True)
|
||||
exe = macos / "ReynaCLIHost"
|
||||
exe.write_bytes(b"fakebinarycontent")
|
||||
exe.chmod(0o755)
|
||||
plist_dict = ab.build_app_bundle_info_plist_dict()
|
||||
# allow custom bundle_id for negative tests
|
||||
if bundle_id != plist_dict.get("CFBundleIdentifier"):
|
||||
plist_dict = dict(plist_dict)
|
||||
plist_dict["CFBundleIdentifier"] = bundle_id
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(plist_dict, f)
|
||||
return bundle
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# app_bundle layer
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_app_bundle_at_path_success(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
def run_ok(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle_at_path(bundle, runner=run_ok)
|
||||
assert res["ok"] is True
|
||||
assert res["signature_verified"] is True
|
||||
assert res["is_ad_hoc"] is False
|
||||
assert res["bundle_identifier_matches"] is True
|
||||
|
||||
|
||||
def test_validate_app_bundle_at_path_rejects_relative(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
rel = Path("relative/Reyna CLI.app")
|
||||
res = ab.validate_app_bundle_at_path(rel)
|
||||
assert res["ok"] is False
|
||||
assert any("absolute" in e.lower() for e in res["errors"])
|
||||
|
||||
|
||||
def test_validate_app_bundle_at_path_rejects_wrong_suffix(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
p = tmp_path / "notapp"
|
||||
p.mkdir()
|
||||
res = ab.validate_app_bundle_at_path(p)
|
||||
assert res["ok"] is False
|
||||
assert any(".app" in e.lower() for e in res["errors"])
|
||||
|
||||
|
||||
def test_validate_app_bundle_at_path_rejects_symlink(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
real_root = tmp_path / "real"
|
||||
real_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(real_root)
|
||||
link = tmp_path / "Reyna CLI.app"
|
||||
link.symlink_to(bundle)
|
||||
|
||||
res = ab.validate_app_bundle_at_path(link)
|
||||
assert res["ok"] is False
|
||||
assert any("symlink" in e.lower() for e in res["errors"])
|
||||
|
||||
|
||||
def test_validate_app_bundle_at_path_rejects_ad_hoc(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
def run_adhoc(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="Signature=adhoc\nTeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle_at_path(bundle, runner=run_adhoc)
|
||||
assert res["ok"] is False
|
||||
assert res["is_ad_hoc"] is True
|
||||
|
||||
|
||||
def test_install_prebuilt_app_bundle_success_no_xcodebuild(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src_root = tmp_path / "gui-build"
|
||||
src_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
|
||||
calls = []
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
assert isinstance(args, list)
|
||||
calls.append(list(args))
|
||||
if args and args[0] == "xcodebuild":
|
||||
raise AssertionError("xcodebuild must NOT be called on prebuilt path")
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.install_prebuilt_app_bundle(source_bundle_path=bundle, repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is True
|
||||
assert res["action"] == "install_prebuilt_app_bundle"
|
||||
assert "signature_verified" in res
|
||||
# Must have copied to dist
|
||||
dist_bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
assert dist_bundle.exists()
|
||||
assert (dist_bundle / "Contents" / "MacOS" / "ReynaCLIHost").exists()
|
||||
# No xcodebuild calls
|
||||
assert all(c[0] != "xcodebuild" for c in calls)
|
||||
# Must have called codesign validation for source and copy (at least 2 verifies)
|
||||
verify_calls = [c for c in calls if c[:3] == ["codesign", "--verify", "--deep"]]
|
||||
assert len(verify_calls) >= 2
|
||||
|
||||
|
||||
def test_install_prebuilt_app_bundle_invalid_prevents_copy(tmp_path, monkeypatch):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src_root = tmp_path / "gui-build"
|
||||
src_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
|
||||
def runner_bad(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="verify fail")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
# Patch copy to detect if called
|
||||
copied = {"called": False}
|
||||
original_copy = ab._copy_app_bundle_atomic
|
||||
|
||||
def tracking_copy(src, dst):
|
||||
copied["called"] = True
|
||||
return original_copy(src, dst)
|
||||
|
||||
monkeypatch.setattr(ab, "_copy_app_bundle_atomic", tracking_copy)
|
||||
|
||||
res = ab.install_prebuilt_app_bundle(source_bundle_path=bundle, repo_root=repo_root, runner=runner_bad)
|
||||
assert res["ok"] is False
|
||||
assert copied["called"] is False, "must not copy if source validation fails"
|
||||
dist_bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
assert not dist_bundle.exists()
|
||||
|
||||
|
||||
def test_install_prebuilt_app_bundle_rejects_symlink_and_relative(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
real_root = tmp_path / "real"
|
||||
real_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(real_root)
|
||||
link = tmp_path / "Reyna CLI.app"
|
||||
link.symlink_to(bundle)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res_link = ab.install_prebuilt_app_bundle(source_bundle_path=link, repo_root=repo_root, runner=runner)
|
||||
assert res_link["ok"] is False
|
||||
assert "symlink" in res_link["error"].lower()
|
||||
|
||||
# Relative
|
||||
rel = Path("relative/Reyna CLI.app")
|
||||
res_rel = ab.install_prebuilt_app_bundle(source_bundle_path=rel, repo_root=repo_root, runner=runner)
|
||||
assert res_rel["ok"] is False
|
||||
assert "absolute" in res_rel["error"].lower()
|
||||
|
||||
# Wrong suffix
|
||||
wrong = tmp_path / "wrong.appstuff"
|
||||
wrong.mkdir()
|
||||
res_suffix = ab.install_prebuilt_app_bundle(source_bundle_path=wrong, repo_root=repo_root, runner=runner)
|
||||
assert res_suffix["ok"] is False
|
||||
assert ".app" in res_suffix["error"].lower()
|
||||
|
||||
|
||||
def test_install_prebuilt_no_secret_leak_in_result(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
|
||||
secret_team = "TEAM_SUPERSECRET123"
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0, stdout=f"Authority=Secret {secret_team}")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr=f"TeamIdentifier={secret_team}\nAuthority=Apple Development: Foo ({secret_team})\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.install_prebuilt_app_bundle(source_bundle_path=bundle, repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is True
|
||||
# Stringified result must not contain raw team identifier output (we filter generically)
|
||||
# Our runner purposely returns secret in stderr, but result should not echo stderr verbatim
|
||||
import json
|
||||
|
||||
res_str = json.dumps(res)
|
||||
# The implementation stores no raw codesign output, only booleans
|
||||
assert "Authority=Apple" not in res_str
|
||||
assert secret_team not in res_str or res.get("validation", {}).get("signature_verified") is True and secret_team not in str(res.get("validation", {}).get("errors", []))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# privacy_host layer — prebuilt route
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_privacy_host_install_prebuilt_success_no_xcodebuild(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod, app_bundle as ab_mod
|
||||
|
||||
src_root = tmp_path / "gui"
|
||||
src_root.mkdir()
|
||||
src_bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
sock = tmp_path / "priv" / "reyna-cli.sock"
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
logd = tmp_path / "Logs"
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
assert isinstance(args, list)
|
||||
calls.append(list(args))
|
||||
if args and args[0] == "xcodebuild":
|
||||
raise AssertionError("xcodebuild must not be called when prebuilt path supplied")
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
result = ph_mod.install_privacy_host_service(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
socket_path=sock,
|
||||
plist_path=plist,
|
||||
log_dir=logd,
|
||||
prebuilt_app_bundle_path=src_bundle,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert plist.exists()
|
||||
# No xcodebuild
|
||||
assert all(c[0] != "xcodebuild" for c in calls)
|
||||
# Must have bootout+bootstrap
|
||||
assert ["launchctl", "bootout", "gui/501/com.reyna.cli.privacy-host"] in calls
|
||||
assert ["launchctl", "bootstrap", "gui/501", str(plist)] in calls
|
||||
# Dist bundle exists
|
||||
dist_bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
assert dist_bundle.exists()
|
||||
|
||||
|
||||
def test_privacy_host_install_prebuilt_invalid_prevents_plist_and_launchctl(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
src_root = tmp_path / "gui"
|
||||
src_root.mkdir()
|
||||
src_bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
sock = tmp_path / "priv" / "reyna-cli.sock"
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
logd = tmp_path / "Logs"
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
calls.append(list(args))
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="fail")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=not set\nSignature=adhoc\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
result = ph_mod.install_privacy_host_service(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
socket_path=sock,
|
||||
plist_path=plist,
|
||||
log_dir=logd,
|
||||
prebuilt_app_bundle_path=src_bundle,
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert not plist.exists(), "plist must not be written if prebuilt validation fails"
|
||||
# No launchctl should have been called
|
||||
launch_calls = [c for c in calls if c and c[0] == "launchctl"]
|
||||
assert len(launch_calls) == 0, f"launchctl must not be called on invalid source, got {launch_calls}"
|
||||
|
||||
|
||||
def test_privacy_host_install_prebuilt_rejects_symlink_and_relative(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
real_root = tmp_path / "real"
|
||||
real_root.mkdir()
|
||||
real_bundle = _make_fake_app_bundle(real_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
|
||||
link = tmp_path / "Reyna CLI.app"
|
||||
link.symlink_to(real_bundle)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res_link = ph_mod.install_privacy_host_service(
|
||||
runner=runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
plist_path=plist,
|
||||
prebuilt_app_bundle_path=link,
|
||||
)
|
||||
assert res_link["ok"] is False
|
||||
assert "symlink" in res_link["error"].lower()
|
||||
|
||||
# Relative
|
||||
rel = Path("relative/Reyna CLI.app")
|
||||
res_rel = ph_mod.install_privacy_host_service(
|
||||
runner=runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
plist_path=plist,
|
||||
prebuilt_app_bundle_path=rel,
|
||||
)
|
||||
assert res_rel["ok"] is False
|
||||
assert "absolute" in res_rel["error"].lower()
|
||||
|
||||
|
||||
def test_privacy_host_install_default_still_calls_xcodebuild(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod, app_bundle as ab_mod
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
sock = tmp_path / "priv" / "reyna-cli.sock"
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
logd = tmp_path / "Logs"
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
calls.append(list(args))
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
result = ph_mod.install_privacy_host_service(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
socket_path=sock,
|
||||
plist_path=plist,
|
||||
log_dir=logd,
|
||||
signing_identity="Test ID",
|
||||
prebuilt_app_bundle_path=None,
|
||||
)
|
||||
assert result["ok"] is True
|
||||
xcode_calls = [c for c in calls if c and c[0] == "xcodebuild"]
|
||||
assert len(xcode_calls) >= 1, "default install must still call xcodebuild"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# CLI wiring
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_install_has_app_bundle_option():
|
||||
res = runner.invoke(app, ["privacy-host", "install", "--help"])
|
||||
assert res.exit_code == 0
|
||||
out = res.stdout.lower()
|
||||
assert "app-bundle" in out
|
||||
|
||||
|
||||
def test_cli_install_app_bundle_prebuilt_success_mocked(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
src_root = tmp_path / "gui"
|
||||
src_root.mkdir()
|
||||
src_bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_install(prebuilt_app_bundle_path=None, **kwargs):
|
||||
captured["prebuilt"] = prebuilt_app_bundle_path
|
||||
assert prebuilt_app_bundle_path is not None
|
||||
assert Path(prebuilt_app_bundle_path).is_absolute()
|
||||
assert str(prebuilt_app_bundle_path).endswith(".app")
|
||||
return {"ok": True, "action": "install", "app_bundle_path": str(prebuilt_app_bundle_path)}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "install_privacy_host_service", lambda **kw: fake_install(**kw))
|
||||
|
||||
res = runner.invoke(app, ["privacy-host", "install", "--app-bundle", str(src_bundle), "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["prebuilt"] == src_bundle
|
||||
|
||||
|
||||
def test_cli_install_app_bundle_rejects_relative_and_symlink(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
# Should fail at CLI layer before calling service, for relative
|
||||
def should_not_be_called(**kwargs):
|
||||
raise AssertionError("service must not be called when CLI rejects path")
|
||||
|
||||
monkeypatch.setattr(ph_mod, "install_privacy_host_service", lambda **kw: should_not_be_called(**kw))
|
||||
|
||||
# Relative
|
||||
res_rel = runner.invoke(app, ["privacy-host", "install", "--app-bundle", "relative/Reyna CLI.app", "--json"])
|
||||
assert res_rel.exit_code != 0
|
||||
assert "absolute" in res_rel.stdout.lower()
|
||||
|
||||
# Symlink
|
||||
real_root = tmp_path / "real"
|
||||
real_root.mkdir()
|
||||
real_bundle = _make_fake_app_bundle(real_root)
|
||||
link = tmp_path / "Reyna CLI.app"
|
||||
link.symlink_to(real_bundle)
|
||||
|
||||
res_link = runner.invoke(app, ["privacy-host", "install", "--app-bundle", str(link), "--json"])
|
||||
assert res_link.exit_code != 0
|
||||
assert "symlink" in res_link.stdout.lower()
|
||||
|
||||
|
||||
def test_cli_install_default_no_app_bundle_calls_build(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_install(prebuilt_app_bundle_path=None, **kwargs):
|
||||
captured["prebuilt"] = prebuilt_app_bundle_path
|
||||
return {"ok": True, "action": "install", "app_bundle_path": "/fake/dist/Reyna CLI.app"}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "install_privacy_host_service", lambda **kw: fake_install(**kw))
|
||||
|
||||
res = runner.invoke(app, ["privacy-host", "install", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["prebuilt"] is None, "default should not pass prebuilt path"
|
||||
|
||||
|
||||
def test_no_deriveddata_scan_in_prebuilt_code():
|
||||
from pathlib import Path
|
||||
|
||||
src_app_bundle = Path("/Users/adolforeyna/Projects/reyna-cli/src/reyna_cli/app_bundle.py").read_text()
|
||||
src_privacy_host = Path("/Users/adolforeyna/Projects/reyna-cli/src/reyna_cli/privacy_host.py").read_text()
|
||||
# Prebuilt functions must not scan DerivedData automatically
|
||||
# They should not listdir or glob DerivedData without explicit path
|
||||
# Check that install_prebuilt_app_bundle does not reference DerivedData path discovery
|
||||
assert "install_prebuilt_app_bundle" in src_app_bundle
|
||||
# Ensure function body does not contain DerivedData scan (like os.walk or glob of DerivedData)
|
||||
# Simple heuristic: function definition area should not contain "DerivedData" search
|
||||
import re
|
||||
|
||||
# Extract install_prebuilt function
|
||||
m = re.search(r"def install_prebuilt_app_bundle.*?^def ", src_app_bundle, flags=re.DOTALL | re.MULTILINE)
|
||||
if m:
|
||||
func_text = m.group(0)
|
||||
# Should not contain "DerivedData" except maybe in comments about NOT using it
|
||||
# Allow at most trivial mention, but not listdir/glob
|
||||
assert "glob" not in func_text.lower() or "deriveddata" not in func_text.lower()
|
||||
assert "os.scandir" not in func_text.lower()
|
||||
assert "os.walk" not in func_text.lower()
|
||||
# privacy_host prebuilt path should not call build_app_bundle
|
||||
# It should have conditional: if prebuilt_app_bundle_path is not None -> install_prebuilt, else build
|
||||
assert "prebuilt_app_bundle_path" in src_privacy_host
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests for PrivacyClient — TDD with real Unix socket servers, no socket mocks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from reyna_cli.privacy_client import (
|
||||
PrivacyClient,
|
||||
PrivacyClientError,
|
||||
default_socket_path,
|
||||
)
|
||||
|
||||
|
||||
def test_default_socket_path():
|
||||
p = default_socket_path()
|
||||
# must be Path and match ~/Library/Application Support/reyna-cli/privacy/reyna-cli.sock
|
||||
expected = Path.home() / "Library/Application Support/reyna-cli/privacy/reyna-cli.sock"
|
||||
# Also accept expanded: Path.home() / "Library/Application Support/reyna-cli/privacy/reyna-cli.sock"
|
||||
# Compare string representation or Path equality
|
||||
assert isinstance(p, Path)
|
||||
assert p == expected
|
||||
assert str(p).endswith("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
|
||||
|
||||
|
||||
# --- helpers for real socket server ---
|
||||
|
||||
class OneShotServer:
|
||||
"""Simple real Unix socket server that handles one connection with a custom handler."""
|
||||
|
||||
def __init__(self, handler):
|
||||
self.handler = handler
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.sock_path = Path(self.tmpdir.name) / "test.sock"
|
||||
self._thread = None
|
||||
self._ready = threading.Event()
|
||||
self._done = threading.Event()
|
||||
self.exception = None
|
||||
|
||||
def start(self):
|
||||
def run():
|
||||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
srv.bind(str(self.sock_path))
|
||||
srv.listen(1)
|
||||
self._ready.set()
|
||||
srv.settimeout(5)
|
||||
try:
|
||||
conn, _ = srv.accept()
|
||||
except socket.timeout:
|
||||
return
|
||||
try:
|
||||
self.handler(conn)
|
||||
except Exception as e:
|
||||
self.exception = e
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
srv.close()
|
||||
self._done.set()
|
||||
|
||||
self._thread = threading.Thread(target=run, daemon=True)
|
||||
self._thread.start()
|
||||
assert self._ready.wait(timeout=3), "server failed to start"
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self._done.wait(timeout=3)
|
||||
if self._thread:
|
||||
self._thread.join(timeout=1)
|
||||
self.tmpdir.cleanup()
|
||||
if self.exception:
|
||||
raise self.exception
|
||||
|
||||
def __enter__(self):
|
||||
return self.start()
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.stop()
|
||||
|
||||
|
||||
def read_one_line(conn: socket.socket, timeout=2) -> dict:
|
||||
conn.settimeout(timeout)
|
||||
buf = b""
|
||||
while b"\n" not in buf:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
line = buf.split(b"\n")[0]
|
||||
return json.loads(line.decode("utf-8"))
|
||||
|
||||
|
||||
def test_call_happy_path_sends_one_json_line_and_validates():
|
||||
received = {}
|
||||
|
||||
def handler(conn):
|
||||
# read exactly one json line
|
||||
conn.settimeout(2)
|
||||
data = b""
|
||||
while not data.endswith(b"\n"):
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
# ensure only one line sent (count newline)
|
||||
if data.count(b"\n") > 1:
|
||||
raise AssertionError("client sent more than one line")
|
||||
assert data.endswith(b"\n")
|
||||
obj = json.loads(data.decode())
|
||||
received.update(obj)
|
||||
assert "id" in obj and isinstance(obj["id"], str) and obj["id"]
|
||||
assert obj["operation"] == "service.health"
|
||||
assert obj["arguments"] == {"x": 1}
|
||||
# echo back with same id
|
||||
resp = {"id": obj["id"], "ok": True, "result": {"status": "ok"}}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
resp = client.call("service.health", {"x": 1})
|
||||
|
||||
assert resp["ok"] is True
|
||||
assert resp["result"]["status"] == "ok"
|
||||
assert received["id"]
|
||||
# id uniqueness check - call again should be different
|
||||
second_id = {}
|
||||
|
||||
def handler2(conn):
|
||||
obj = read_one_line(conn)
|
||||
second_id["id"] = obj["id"]
|
||||
resp = {"id": obj["id"], "ok": True, "result": {}}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler2) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
client.call("service.health", {})
|
||||
|
||||
assert received["id"] != second_id["id"]
|
||||
|
||||
|
||||
def test_call_missing_socket_raises():
|
||||
tmp = Path(tempfile.gettempdir()) / f"nonexistent-{uuid.uuid4().hex}.sock"
|
||||
client = PrivacyClient(socket_path=tmp, timeout=1)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)socket|missing|not found|connect|no such"):
|
||||
client.call("service.health", {})
|
||||
|
||||
|
||||
def test_call_timeout_raises():
|
||||
def handler(conn):
|
||||
# never respond, just sleep longer than client timeout
|
||||
time.sleep(3)
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=0.3)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)timeout|timed out"):
|
||||
client.call("service.health", {})
|
||||
|
||||
|
||||
def test_call_malformed_json_response_raises():
|
||||
def handler(conn):
|
||||
_ = read_one_line(conn)
|
||||
conn.sendall(b"not-json\n")
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)malformed|invalid|json"):
|
||||
client.call("service.health", {})
|
||||
|
||||
|
||||
def test_call_mismatched_id_raises():
|
||||
def handler(conn):
|
||||
obj = read_one_line(conn)
|
||||
resp = {"id": "different-" + obj["id"], "ok": True, "result": {}}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)mismatch|id"):
|
||||
client.call("service.health", {})
|
||||
|
||||
|
||||
def test_call_ok_false_raises():
|
||||
def handler(conn):
|
||||
obj = read_one_line(conn)
|
||||
resp = {"id": obj["id"], "ok": False, "error": "forbidden"}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)forbidden|ok.*false|false"):
|
||||
client.call("service.health", {})
|
||||
|
||||
|
||||
def test_call_payload_too_large_before_connect():
|
||||
# 64 KiB limit
|
||||
large_arg = "x" * (70 * 1024)
|
||||
# Use a non-existent socket path; should fail on size check BEFORE attempting connect
|
||||
# So we can tell it didn't try to connect if error mentions size
|
||||
tmp = Path(tempfile.gettempdir()) / f"nonexistent-{uuid.uuid4().hex}.sock"
|
||||
client = PrivacyClient(socket_path=tmp, timeout=1)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)64|size|large|payload|KiB"):
|
||||
client.call("service.health", {"big": large_arg})
|
||||
|
||||
# Also test just over limit with real server not needed - ensure no socket file created attempt is made
|
||||
# To be sure it didn't connect, we use a server and check that handler was NOT called
|
||||
called = {"yes": False}
|
||||
|
||||
def handler(conn):
|
||||
called["yes"] = True
|
||||
obj = read_one_line(conn)
|
||||
resp = {"id": obj["id"], "ok": True}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)size|large|payload|64"):
|
||||
client.call("op", {"big": large_arg})
|
||||
# give short time for any unwanted connection
|
||||
time.sleep(0.2)
|
||||
assert not called["yes"], "should not have connected when payload too large"
|
||||
|
||||
|
||||
def test_call_no_arguments_defaults_to_empty():
|
||||
def handler(conn):
|
||||
obj = read_one_line(conn)
|
||||
assert obj["arguments"] == {}
|
||||
resp = {"id": obj["id"], "ok": True, "result": "empty-ok"}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
resp = client.call("calendar.list")
|
||||
assert resp["result"] == "empty-ok"
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Foundation slice: privacy contract — RED phase (should fail until module exists)."""
|
||||
|
||||
def test_allowlist_registry_includes_required_operations():
|
||||
from reyna_cli.privacy_contract import ALLOWED_OPERATIONS
|
||||
|
||||
assert "service.health" in ALLOWED_OPERATIONS
|
||||
assert "calendar.list" in ALLOWED_OPERATIONS
|
||||
|
||||
|
||||
def test_command_to_operation_mapping():
|
||||
from reyna_cli.privacy_contract import command_to_operation
|
||||
|
||||
assert command_to_operation("calendar_list_calendars") == "calendar.list"
|
||||
|
||||
|
||||
def test_scrub_privacy_result_redacts_sensitive_keys_case_insensitive_recursive():
|
||||
from reyna_cli.privacy_contract import scrub_privacy_result
|
||||
|
||||
payload = {
|
||||
"ok": True,
|
||||
"token": "should-redact",
|
||||
"nested": {
|
||||
"Password": "secret123",
|
||||
"safe": "keep-me",
|
||||
"deep": [{"SECRET": "hide", "value": 1}, {"Api_Key": "abc", "x": "y"}],
|
||||
},
|
||||
"Authorization": "Bearer xyz",
|
||||
"api_key": "key123",
|
||||
"normal": "visible",
|
||||
}
|
||||
scrubbed = scrub_privacy_result(payload)
|
||||
|
||||
assert scrubbed["token"] == "[REDACTED]"
|
||||
assert scrubbed["nested"]["Password"] == "[REDACTED]"
|
||||
assert scrubbed["nested"]["safe"] == "keep-me"
|
||||
assert scrubbed["nested"]["deep"][0]["SECRET"] == "[REDACTED]"
|
||||
assert scrubbed["nested"]["deep"][0]["value"] == 1
|
||||
assert scrubbed["nested"]["deep"][1]["Api_Key"] == "[REDACTED]"
|
||||
assert scrubbed["Authorization"] == "[REDACTED]"
|
||||
assert scrubbed["api_key"] == "[REDACTED]"
|
||||
assert scrubbed["normal"] == "visible"
|
||||
# original unchanged (no mutation)
|
||||
assert payload["token"] == "should-redact"
|
||||
|
||||
|
||||
def test_scrub_privacy_result_preserves_non_sensitive_and_handles_lists():
|
||||
from reyna_cli.privacy_contract import scrub_privacy_result
|
||||
|
||||
data = {"ok": True, "value": {"calendar": "Home"}}
|
||||
assert scrub_privacy_result(data) == {"ok": True, "value": {"calendar": "Home"}}
|
||||
|
||||
data2 = [{"token": "a"}, {"safe": "b"}]
|
||||
assert scrub_privacy_result(data2) == [{"token": "[REDACTED]"}, {"safe": "b"}]
|
||||
|
||||
|
||||
def test_scrub_exact_key_match_only():
|
||||
"""Only exactly token/password/secret/api_key/authorization should be redacted."""
|
||||
from reyna_cli.privacy_contract import scrub_privacy_result
|
||||
|
||||
payload = {
|
||||
"my_token": "should-not-redact",
|
||||
"tokenizer": "keep",
|
||||
"passwords": "keep",
|
||||
"api_key_id": "keep",
|
||||
"secret": "redact",
|
||||
}
|
||||
scrubbed = scrub_privacy_result(payload)
|
||||
assert scrubbed["my_token"] == "should-not-redact"
|
||||
assert scrubbed["tokenizer"] == "keep"
|
||||
assert scrubbed["passwords"] == "keep"
|
||||
assert scrubbed["api_key_id"] == "keep"
|
||||
assert scrubbed["secret"] == "[REDACTED]"
|
||||
@@ -0,0 +1,974 @@
|
||||
"""Tests for privacy_host wrapper — native calendar.list no fallback, status deterministic."""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import stat
|
||||
import plistlib
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
def test_native_calendar_list_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "abc", "ok": True, "result": [{"title": "Work"}]}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
result = ph_mod.native_calendar_list()
|
||||
|
||||
assert captured["op"] == "calendar.list"
|
||||
assert captured["args"] == {}
|
||||
assert result["ok"] is True
|
||||
assert result["source"] == "native_privacy_host"
|
||||
assert result["result"] == [{"title": "Work"}]
|
||||
|
||||
|
||||
def test_native_calendar_list_failure_surfaces(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli.privacy_client import PrivacyClientError
|
||||
|
||||
class FakeFail:
|
||||
def call(self, op, args):
|
||||
raise PrivacyClientError("privacy socket not found at /tmp/x")
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeFail)
|
||||
|
||||
with pytest.raises(PrivacyClientError):
|
||||
ph_mod.native_calendar_list()
|
||||
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "MCPClient" not in src
|
||||
assert "macmini_client" not in src
|
||||
|
||||
|
||||
def test_no_mcp_import_in_privacy_host():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
# Must not reference MCP fallback helpers
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "macmini_client" not in src
|
||||
assert "MCPClient" not in src
|
||||
# Must not import mcp module
|
||||
assert "from reyna_cli.mcp import" not in src
|
||||
assert "import mcp" not in src.lower()
|
||||
|
||||
|
||||
def test_native_calendar_list_rejects_success_without_result(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
class IncompleteClient:
|
||||
def call(self, op, args):
|
||||
return {"id": "abc", "ok": True}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", IncompleteClient)
|
||||
with pytest.raises(RuntimeError, match="missing result"):
|
||||
ph_mod.native_calendar_list()
|
||||
|
||||
def test_cli_calendars_uses_native_wrapper(monkeypatch):
|
||||
"""Prove `macmini calendar calendars` routes through native wrapper."""
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_native():
|
||||
calls["count"] += 1
|
||||
return {"ok": True, "source": "native_privacy_host", "result": [{"id": "1"}]}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_native)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "calendars", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
import json
|
||||
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["source"] == "native_privacy_host"
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_cli_calendars_json_flag_propagates(monkeypatch):
|
||||
def fake_native():
|
||||
return {"ok": True, "source": "native_privacy_host", "result": []}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_native)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "calendars", "--json"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_cli_calendars_no_fallback_on_native_failure(monkeypatch):
|
||||
from reyna_cli.privacy_client import PrivacyClientError
|
||||
|
||||
def fake_native_fail():
|
||||
raise PrivacyClientError("privacy socket not found")
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_native_fail)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "calendars", "--json"])
|
||||
# fail() triggers Exit 1 with ok:false payload
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_privacy_host_status_no_filesystem_creation(monkeypatch, tmp_path):
|
||||
"""status must not create/start, and must report deterministic paths."""
|
||||
fake_sock_parent = tmp_path / "reyna-privacy-status-test"
|
||||
fake_sock = fake_sock_parent / "reyna-cli.sock"
|
||||
assert not fake_sock_parent.exists()
|
||||
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
monkeypatch.setattr(ph_mod, "default_socket_path", lambda: fake_sock)
|
||||
|
||||
result = runner.invoke(app, ["privacy-host", "status", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
import json
|
||||
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert "socket_path" in payload
|
||||
assert "socket_exists" in payload
|
||||
assert payload["socket_exists"] is False
|
||||
assert not fake_sock_parent.exists()
|
||||
assert "build_path" in payload or "socket_dir" in payload
|
||||
assert str(fake_sock) in payload["socket_path"]
|
||||
|
||||
|
||||
def test_no_generic_arbitrary_operation_cli():
|
||||
"""Ensure we didn't expose a generic arbitrary operation CLI yet."""
|
||||
result = runner.invoke(app, ["privacy-host", "--help"])
|
||||
assert result.exit_code == 0
|
||||
out = result.stdout.lower()
|
||||
assert "status" in out
|
||||
assert "call" not in out
|
||||
|
||||
result2 = runner.invoke(app, ["macmini", "call", "--help"])
|
||||
assert result2.exit_code == 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# New managed-lifecycle tests — strict TDD, mocked I/O only
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_plist_deterministic_secure_contents(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
sock = tmp_path / "sock" / "reyna-cli.sock"
|
||||
logd = tmp_path / "logs"
|
||||
|
||||
plist = ph_mod.build_privacy_host_plist(repo_root=repo_root, socket_path=sock, log_dir=logd)
|
||||
|
||||
# Label
|
||||
assert plist["Label"] == "com.reyna.cli.privacy-host"
|
||||
# No TCP args/ports
|
||||
prog = plist["ProgramArguments"]
|
||||
assert isinstance(prog, list) and len(prog) == 3
|
||||
# Binary path deterministic per task spec – now stable signed .app bundle for TCC identity
|
||||
expected_bundle_exe = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app" / "Contents" / "MacOS" / "ReynaCLIHost"
|
||||
assert str(prog[0]) == str(expected_bundle_exe)
|
||||
assert prog[1] == "--socket"
|
||||
assert prog[2] == str(sock)
|
||||
combined = " ".join(prog).lower()
|
||||
assert "--port" not in combined
|
||||
assert "tcp" not in combined
|
||||
assert "0.0.0.0" not in combined
|
||||
assert "127.0.0.1" not in combined
|
||||
|
||||
# Required LaunchAgent keys
|
||||
assert plist["RunAtLoad"] is True
|
||||
assert plist["KeepAlive"] is True
|
||||
assert plist["ProcessType"] == "Interactive"
|
||||
assert plist["WorkingDirectory"] == str(repo_root)
|
||||
assert plist["StandardOutPath"] == str(logd / "privacy-host.out.log")
|
||||
assert plist["StandardErrorPath"] == str(logd / "privacy-host.error.log")
|
||||
# No env/secrets
|
||||
assert "EnvironmentVariables" not in plist
|
||||
assert "Environment" not in plist
|
||||
|
||||
# Also check plistlib serializable
|
||||
data = plistlib.dumps(plist)
|
||||
loaded = plistlib.loads(data)
|
||||
assert loaded == plist
|
||||
|
||||
|
||||
def test_plist_contains_only_unix_socket_invocation():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
plist = ph_mod.build_privacy_host_plist()
|
||||
prog_str = " ".join(plist["ProgramArguments"])
|
||||
# Must contain --socket
|
||||
assert "--socket" in prog_str
|
||||
# Must NOT contain TCP indicators
|
||||
forbidden = ["--port", "--host", "tcp://", "0.0.0.0", "127.0.0.1", ":8080", ":3000"]
|
||||
lower = prog_str.lower()
|
||||
for token in forbidden:
|
||||
assert token.lower() not in lower, f"forbidden token {token} in {prog_str}"
|
||||
# Source must also not contain TCP ports in file itself (extra hardening)
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
# We allow portion about TCP check in tests/comments but not in plist builder path that would inject TCP
|
||||
# Instead ensure builder uses only socket arg
|
||||
assert "ProgramArguments" in src
|
||||
|
||||
|
||||
def test_build_release_command_exact_arg_array_no_shell():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
cmd = ph_mod.build_release_command()
|
||||
# Now expects xcodebuild safe arg array (Xcode owns signing)
|
||||
assert isinstance(cmd, list)
|
||||
assert all(isinstance(x, str) for x in cmd)
|
||||
assert cmd[0] == "xcodebuild"
|
||||
assert "-project" in cmd
|
||||
assert "-scheme" in cmd
|
||||
assert "-target" not in cmd, "must use -scheme for valid derivedDataPath builds"
|
||||
assert "Reyna CLI" in cmd
|
||||
assert "-configuration" in cmd
|
||||
assert "Release" in cmd
|
||||
assert "-derivedDataPath" in cmd
|
||||
assert "build" in cmd
|
||||
assert "--sign" not in cmd
|
||||
# Must be list, not string, no shell
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
assert "shell=True" not in src
|
||||
assert "shell=\"" not in src
|
||||
# No manual codesign --sign construction in module
|
||||
assert '["codesign", "--force"' not in src
|
||||
|
||||
|
||||
def test_build_release_command_no_params():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
import inspect
|
||||
sig = inspect.signature(ph_mod.build_release_command)
|
||||
assert len(sig.parameters) == 0, f"should have no params, got {list(sig.parameters)}"
|
||||
|
||||
|
||||
def test_status_reports_plist_and_socket_and_pid_from_runner_only(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
fake_repo = tmp_path / "repo"
|
||||
fake_repo.mkdir()
|
||||
bundle = fake_repo / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
import plistlib as _pl
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
_pl.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
fake_sock = tmp_path / "sock" / "reyna-cli.sock"
|
||||
fake_sock.parent.mkdir()
|
||||
fake_sock.write_text("dummy")
|
||||
|
||||
fake_plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
fake_plist.parent.mkdir()
|
||||
fake_plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = " pid = 12345\n state = running\n"
|
||||
stderr = ""
|
||||
|
||||
def fake_runner(args, **kwargs):
|
||||
calls.append(list(args))
|
||||
assert isinstance(args, list), "must be arg array, not shell string"
|
||||
if args and args[0] == "codesign":
|
||||
if "--verify" in args:
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return P()
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\n"
|
||||
return P()
|
||||
return Proc()
|
||||
assert args[0] == "launchctl"
|
||||
assert args[1] == "print"
|
||||
assert args[2].startswith("gui/")
|
||||
assert ph_mod.PRIVACY_HOST_LABEL in args[2]
|
||||
return Proc()
|
||||
|
||||
status = ph_mod.privacy_host_service_status(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
plist_path_override=fake_plist,
|
||||
socket_path_override=fake_sock,
|
||||
repo_root_override=fake_repo,
|
||||
)
|
||||
|
||||
assert status["ok"] is True
|
||||
assert status["plist_path"] == str(fake_plist)
|
||||
assert status["plist_exists"] is True
|
||||
assert status["socket_path"] == str(fake_sock)
|
||||
assert status["socket_exists"] is True
|
||||
assert status["socket_type"] in ("file", "socket", "dir", "other")
|
||||
assert status["pid"] == 12345
|
||||
assert status["active"] is True
|
||||
launch_calls = [c for c in calls if c[0] == "launchctl"]
|
||||
assert len(launch_calls) == 1
|
||||
assert launch_calls[0][0] == "launchctl"
|
||||
assert status.get("bundle_exists") is True
|
||||
assert status.get("signature_verified") is True
|
||||
assert (fake_repo / "native" / "ReynaCLIHost" / "dist").exists()
|
||||
|
||||
|
||||
def test_status_failure_no_throw_structured(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
def failing_runner(args, **kwargs):
|
||||
raise RuntimeError("launchctl not found")
|
||||
|
||||
status = ph_mod.privacy_host_service_status(
|
||||
runner=failing_runner,
|
||||
uid=501,
|
||||
plist_path_override=tmp_path / "nonexist.plist",
|
||||
socket_path_override=tmp_path / "sock.sock",
|
||||
repo_root_override=tmp_path / "repo",
|
||||
)
|
||||
# Must not throw, must return structured
|
||||
assert status["ok"] is True # ok still True but with errors
|
||||
assert "errors" in status
|
||||
assert any("launchctl" in e for e in status["errors"])
|
||||
assert status["pid"] is None
|
||||
|
||||
|
||||
def test_status_no_filesystem_creation_with_mock_runner(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
sock_parent = tmp_path / "no-create-parent"
|
||||
sock = sock_parent / "reyna-cli.sock"
|
||||
assert not sock_parent.exists()
|
||||
|
||||
class Proc:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "No such file"
|
||||
|
||||
def fake_runner(args, **kwargs):
|
||||
# Ensure no swift
|
||||
assert "swift" not in args[0]
|
||||
return Proc()
|
||||
|
||||
# monkeypatch _plist_path etc via overrides, not global
|
||||
plist_path = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
# Do not create plist_parent to prove status doesn't create it
|
||||
assert not plist_path.parent.exists()
|
||||
|
||||
status = ph_mod.privacy_host_service_status(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
plist_path_override=plist_path,
|
||||
socket_path_override=sock,
|
||||
repo_root_override=tmp_path / "repo-root-no-create",
|
||||
)
|
||||
|
||||
assert not sock_parent.exists()
|
||||
assert not plist_path.parent.exists()
|
||||
assert status["socket_exists"] is False
|
||||
assert status["plist_exists"] is False
|
||||
|
||||
|
||||
def test_status_exact_launchctl_arg_array():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = []
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = "pid = 999\n"
|
||||
stderr = ""
|
||||
|
||||
def runner(args, **kwargs):
|
||||
captured.append(list(args))
|
||||
if args and args[0] == "codesign":
|
||||
# fake validation phase before launchctl
|
||||
if "--verify" in args:
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return P()
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\n"
|
||||
return P()
|
||||
return Proc()
|
||||
|
||||
ph_mod.privacy_host_service_status(runner=runner, uid=123, plist_path_override=Path("/tmp/a.plist"), socket_path_override=Path("/tmp/b.sock"))
|
||||
|
||||
# launchctl print must exist (may not be first due to codesign validation)
|
||||
launch_calls = [c for c in captured if c and c[0] == "launchctl"]
|
||||
assert len(launch_calls) >= 1
|
||||
assert ["launchctl", "print", "gui/123/com.reyna.cli.privacy-host"] in launch_calls
|
||||
# ensure arg array, no shell, no manual --sign
|
||||
for c in captured:
|
||||
assert isinstance(c, list)
|
||||
assert "--sign" not in c or c[0] != "codesign"
|
||||
|
||||
|
||||
def test_install_exact_command_arg_arrays_and_modes(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / "native" / "ReynaCLIHost").mkdir(parents=True)
|
||||
# Required xcodeproj and Info.plist source for build_app_bundle check
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj").write_text("// dummy")
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
|
||||
# Expected derived-data product location that build_app_bundle runner will copy
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"fakebinary")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
sock = tmp_path / "priv" / "reyna-cli.sock"
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
logd = tmp_path / "Logs" / "reyna-cli"
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
def __init__(self, rc=0, stdout="ok", stderr=""):
|
||||
self.returncode = rc
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
assert isinstance(args, list), "must use arg list, no shell"
|
||||
calls.append({"args": list(args), "cwd": str(cwd) if cwd else None})
|
||||
if args and args[0] == "xcodebuild":
|
||||
# Simulate build succeeded; product already exists at derived path
|
||||
return Proc(rc=0, stdout="BUILD SUCCEEDED", stderr="")
|
||||
if args and args[0] == "codesign" and "--sign" in args:
|
||||
# Should NOT happen in new flow – Xcode owns signing; fail if called
|
||||
raise AssertionError(f"manual codesign --sign must not occur, got {args}")
|
||||
if len(args) >= 3 and args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return Proc(rc=0, stdout="", stderr="")
|
||||
if len(args) >= 2 and args[:2] == ["codesign", "-dv"]:
|
||||
return Proc(rc=0, stdout="", stderr="TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n")
|
||||
return Proc(rc=0)
|
||||
|
||||
chmod_calls = []
|
||||
orig_chmod = os.chmod
|
||||
|
||||
def fake_chmod(p, mode, *args, **kwargs):
|
||||
chmod_calls.append((str(p), mode))
|
||||
try:
|
||||
orig_chmod(p, mode, *args, **kwargs)
|
||||
except TypeError:
|
||||
try:
|
||||
orig_chmod(p, mode)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(os, "chmod", fake_chmod)
|
||||
|
||||
result = ph_mod.install_privacy_host_service(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
socket_path=sock,
|
||||
plist_path=plist,
|
||||
log_dir=logd,
|
||||
signing_identity="Test Identity (TEAM123)",
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
# Must be xcodebuild, not swift build
|
||||
xcode_calls = [c for c in calls if c["args"] and c["args"][0] == "xcodebuild"]
|
||||
assert len(xcode_calls) >= 1
|
||||
xb = xcode_calls[0]["args"]
|
||||
assert "-project" in xb
|
||||
assert "-scheme" in xb
|
||||
assert "-target" not in xb
|
||||
assert "Reyna CLI" in xb
|
||||
assert "-configuration" in xb
|
||||
assert "Release" in xb
|
||||
assert "-derivedDataPath" in xb
|
||||
assert "build" in xb
|
||||
# No swift build
|
||||
swift_calls = [c for c in calls if c["args"][:2] == ["swift", "build"]]
|
||||
assert len(swift_calls) == 0, f"swift build must not be used, got {swift_calls}"
|
||||
# No manual codesign --sign
|
||||
sign_calls = [c for c in calls if c["args"][0] == "codesign" and "--sign" in c["args"]]
|
||||
assert len(sign_calls) == 0, f"manual codesign --sign forbidden, got {sign_calls}"
|
||||
|
||||
bootouts = [c for c in calls if c["args"][:2] == ["launchctl", "bootout"]]
|
||||
bootstraps = [c for c in calls if c["args"][:2] == ["launchctl", "bootstrap"]]
|
||||
assert len(bootouts) == 1
|
||||
assert bootouts[0]["args"] == ["launchctl", "bootout", "gui/501/com.reyna.cli.privacy-host"]
|
||||
assert len(bootstraps) == 1
|
||||
assert bootstraps[0]["args"] == ["launchctl", "bootstrap", "gui/501", str(plist)]
|
||||
|
||||
assert sock.parent.exists()
|
||||
assert logd.exists()
|
||||
modes_0700 = [c for c in chmod_calls if c[1] == 0o700]
|
||||
assert len(modes_0700) >= 2
|
||||
assert plist.exists()
|
||||
modes_0600 = [c for c in chmod_calls if c[1] == 0o600 and str(plist) in c[0]]
|
||||
assert len(modes_0600) >= 1
|
||||
|
||||
loaded = plistlib.loads(plist.read_bytes())
|
||||
prog = loaded["ProgramArguments"]
|
||||
assert prog[0] == str(repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app" / "Contents" / "MacOS" / "ReynaCLIHost")
|
||||
assert prog[1] == "--socket"
|
||||
assert prog[2] == str(sock)
|
||||
assert "app_bundle_path" in result
|
||||
assert result["bundle_identifier"] == "com.reyna.cli.privacy-host"
|
||||
assert result.get("signature_verified") is True
|
||||
|
||||
|
||||
def test_start_stop_use_bootstrap_bootout_kickstart_testable(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist.parent.mkdir(parents=True)
|
||||
plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
import plistlib as _pl
|
||||
_pl.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
def __init__(self, rc=0, stdout="", stderr=""):
|
||||
self.returncode = rc
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
def start_runner(args, **kwargs):
|
||||
assert isinstance(args, list)
|
||||
calls.append(list(args))
|
||||
if args and args[0] == "codesign":
|
||||
if "--verify" in args:
|
||||
return Proc(rc=0)
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
return Proc(rc=0, stdout="", stderr="TeamIdentifier=TEAM123\n")
|
||||
return Proc(rc=0)
|
||||
return Proc(rc=0)
|
||||
|
||||
res_start = ph_mod.start_privacy_host_service(runner=start_runner, uid=502, plist_path=plist, repo_root=repo_root)
|
||||
assert res_start["ok"] is True
|
||||
assert ["launchctl", "kickstart", "-k", "gui/502/com.reyna.cli.privacy-host"] in calls
|
||||
|
||||
calls.clear()
|
||||
|
||||
def stop_runner(args, **kwargs):
|
||||
assert isinstance(args, list)
|
||||
calls.append(list(args))
|
||||
return Proc(rc=0)
|
||||
|
||||
res_stop = ph_mod.stop_privacy_host_service(runner=stop_runner, uid=502, plist_path=plist)
|
||||
assert res_stop["ok"] is True
|
||||
assert calls[0] == ["launchctl", "bootout", "gui/502/com.reyna.cli.privacy-host"]
|
||||
|
||||
|
||||
def test_start_idempotence_kickstart_fail_bootstrap_fail_then_kickstart_retry(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist.parent.mkdir(parents=True)
|
||||
plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
import plistlib as _pl
|
||||
_pl.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
def __init__(self, rc, out="", err=""):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = err
|
||||
|
||||
seq = [
|
||||
Proc(1, "", "kickstart failed"),
|
||||
Proc(1, "", "already loaded"),
|
||||
Proc(0, "", ""),
|
||||
]
|
||||
idx = {"i": 0}
|
||||
|
||||
def runner(args, **kwargs):
|
||||
# validation phase codesign calls first
|
||||
if args and args[0] == "codesign":
|
||||
if "--verify" in args:
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return P()
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\n"
|
||||
return P()
|
||||
return Proc(0, "", "")
|
||||
calls.append(list(args))
|
||||
r = seq[idx["i"]]
|
||||
idx["i"] += 1
|
||||
return r
|
||||
|
||||
result = ph_mod.start_privacy_host_service(runner=runner, uid=501, plist_path=plist, repo_root=repo_root)
|
||||
assert result["ok"] is True
|
||||
assert len(calls) == 3
|
||||
assert calls[0] == ["launchctl", "kickstart", "-k", "gui/501/com.reyna.cli.privacy-host"]
|
||||
assert calls[1] == ["launchctl", "bootstrap", "gui/501", str(plist)]
|
||||
assert calls[2] == ["launchctl", "kickstart", "-k", "gui/501/com.reyna.cli.privacy-host"]
|
||||
|
||||
|
||||
def test_start_does_not_treat_arbitrary_bootstrap_failure_as_success(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist.parent.mkdir(parents=True)
|
||||
plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
import plistlib as _pl
|
||||
_pl.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
class Proc:
|
||||
def __init__(self, rc):
|
||||
self.returncode = rc
|
||||
self.stdout = ""
|
||||
self.stderr = "some other failure"
|
||||
|
||||
seq = [Proc(1), Proc(1), Proc(1)]
|
||||
idx = {"i": 0}
|
||||
|
||||
def runner(args, **kwargs):
|
||||
if args and args[0] == "codesign":
|
||||
if "--verify" in args:
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return P()
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\n"
|
||||
return P()
|
||||
return Proc(1)
|
||||
r = seq[idx["i"]]
|
||||
idx["i"] += 1
|
||||
return r
|
||||
|
||||
result = ph_mod.start_privacy_host_service(runner=runner, uid=501, plist_path=plist, repo_root=repo_root)
|
||||
# All three fail -> ok False
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
def test_uninstall_removes_only_exact_plist(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
# Real expected plist path is ~/Library/LaunchAgents/com.reyna.cli.privacy-host.plist
|
||||
# For safety test, we will monkeypatch _plist_path to return our tmp plist
|
||||
real_expected = tmp_path / "Library" / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
real_expected.parent.mkdir(parents=True)
|
||||
real_expected.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
|
||||
# Create a fake socket somewhere else that must NOT be removed
|
||||
fake_sock = tmp_path / "sockdir" / "reyna-cli.sock"
|
||||
fake_sock.parent.mkdir()
|
||||
fake_sock.write_text("keep me")
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
def fake_runner(args, **kwargs):
|
||||
assert isinstance(args, list)
|
||||
calls.append(list(args))
|
||||
return Proc()
|
||||
|
||||
monkeypatch.setattr(ph_mod, "_plist_path", lambda: real_expected)
|
||||
|
||||
result = ph_mod.uninstall_privacy_host_service(runner=fake_runner, uid=503, plist_path=real_expected)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["removed"] is True
|
||||
assert not real_expected.exists()
|
||||
assert fake_sock.exists(), "uninstall must never remove socket arbitrary paths"
|
||||
# Must have called bootout
|
||||
assert ["launchctl", "bootout", "gui/503/com.reyna.cli.privacy-host"] in calls
|
||||
|
||||
# Attempt to remove non-exact plist should be refused
|
||||
other_plist = tmp_path / "other.plist"
|
||||
other_plist.write_text("evil")
|
||||
|
||||
result2 = ph_mod.uninstall_privacy_host_service(runner=fake_runner, uid=503, plist_path=other_plist)
|
||||
assert result2["ok"] is False
|
||||
assert "refusing" in result2["error"].lower()
|
||||
assert other_plist.exists(), "non-exact plist must not be removed"
|
||||
|
||||
|
||||
def test_cli_has_managed_lifecycle_commands():
|
||||
result = runner.invoke(app, ["privacy-host", "--help"])
|
||||
assert result.exit_code == 0
|
||||
out = result.stdout.lower()
|
||||
# Must have all lifecycle commands
|
||||
for cmd in ["status", "install", "start", "stop", "uninstall"]:
|
||||
assert cmd in out, f"{cmd} missing from help: {out}"
|
||||
# Still no generic call
|
||||
assert "call" not in out
|
||||
|
||||
|
||||
def test_cli_install_start_stop_uninstall_with_mocked_helpers(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
# Mock helpers to avoid real I/O
|
||||
def fake_install():
|
||||
return {"ok": True, "action": "install", "results": []}
|
||||
|
||||
def fake_start():
|
||||
return {"ok": True, "action": "start", "results": []}
|
||||
|
||||
def fake_stop():
|
||||
return {"ok": True, "action": "stop", "results": []}
|
||||
|
||||
def fake_uninstall():
|
||||
return {"ok": True, "action": "uninstall", "removed": True, "results": []}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "install_privacy_host_service", lambda *a, **k: fake_install())
|
||||
monkeypatch.setattr(ph_mod, "start_privacy_host_service", lambda *a, **k: fake_start())
|
||||
monkeypatch.setattr(ph_mod, "stop_privacy_host_service", lambda *a, **k: fake_stop())
|
||||
monkeypatch.setattr(ph_mod, "uninstall_privacy_host_service", lambda *a, **k: fake_uninstall())
|
||||
|
||||
for cmd in ["install", "start", "stop", "uninstall"]:
|
||||
res = runner.invoke(app, ["privacy-host", cmd, "--json"])
|
||||
assert res.exit_code == 0, f"{cmd} failed: {res.stdout} {res.stderr}"
|
||||
import json
|
||||
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_no_live_launchctl_swift_in_module_source():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
# Ensure no direct subprocess.run with shell string that would invoke live commands at import time
|
||||
# The module should not execute swift or launchctl on import — check top-level calls
|
||||
# We already tested shell=True absent, now ensure no top-level launchctl/bootstrap call outside functions
|
||||
lines = src.splitlines()
|
||||
# Look for launchctl or swift outside function defs — simple heuristic: any line at column 0 invoking runner?
|
||||
# For this slice, we just ensure module import doesn't trigger side effects by importing again
|
||||
import importlib
|
||||
|
||||
importlib.reload(ph_mod) # should not throw or run launchctl
|
||||
# If reload succeeded without side-effect error, pass
|
||||
assert True
|
||||
|
||||
|
||||
def test_status_payload_includes_new_fields_but_preserves_legacy(monkeypatch, tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
sock = tmp_path / "legacy" / "reyna-cli.sock"
|
||||
# Do not create parent to prove no creation
|
||||
|
||||
monkeypatch.setattr(ph_mod, "default_socket_path", lambda: sock)
|
||||
|
||||
class Proc:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
def fake_runner(args, **kwargs):
|
||||
return Proc()
|
||||
|
||||
payload = ph_mod.privacy_host_status_payload(runner=fake_runner, uid=501)
|
||||
|
||||
# Legacy fields preserved
|
||||
assert "socket_path" in payload
|
||||
assert "socket_dir" in payload
|
||||
assert "build_path" in payload
|
||||
assert "socket_exists" in payload
|
||||
assert payload["socket_exists"] is False
|
||||
# New fields present
|
||||
assert "plist_path" in payload
|
||||
assert "plist_exists" in payload
|
||||
assert "binary_path" in payload
|
||||
assert "pid" in payload
|
||||
assert "active" in payload
|
||||
# No creation
|
||||
assert not sock.parent.exists()
|
||||
|
||||
|
||||
# --- Additional hardening tests ---
|
||||
|
||||
def test_write_plist_atomic_no_world_readable_window(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(ph_mod._write_plist_0600)
|
||||
assert "os.open" in src
|
||||
assert "O_CREAT" in src
|
||||
assert "O_EXCL" in src
|
||||
assert "os.replace" in src
|
||||
assert "fsync" in src
|
||||
|
||||
# Functional: permissions 0600
|
||||
plist_path = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist_path.parent.mkdir(parents=True)
|
||||
# Ensure parent is 0700 as implementation will enforce
|
||||
os.chmod(plist_path.parent, 0o700)
|
||||
ph_mod._write_plist_0600({"Label": "test", "ProgramArguments": ["/bin/true"]}, plist_path)
|
||||
st = plist_path.lstat()
|
||||
assert stat.S_IMODE(st.st_mode) == 0o600
|
||||
# No temp files left
|
||||
leftovers = list(plist_path.parent.glob("*.tmp.*"))
|
||||
assert len(leftovers) == 0, f"temp files left: {leftovers}"
|
||||
|
||||
|
||||
def test_ensure_dir_0700_rejects_symlink(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
link = tmp_path / "linkdir"
|
||||
link.symlink_to(real)
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
ph_mod._ensure_dir_0700(link)
|
||||
|
||||
|
||||
def test_ensure_dir_0700_enforces_0700(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
d = tmp_path / "a" / "b" / "c"
|
||||
ph_mod._ensure_dir_0700(d)
|
||||
assert d.exists()
|
||||
assert stat.S_IMODE(d.lstat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(d.parent.lstat().st_mode) == 0o700 or True # parent also 0700 via parents creation may be checked
|
||||
|
||||
# Re-call should still ensure 0700
|
||||
os.chmod(d, 0o755)
|
||||
ph_mod._ensure_dir_0700(d)
|
||||
assert stat.S_IMODE(d.lstat().st_mode) == 0o700
|
||||
|
||||
|
||||
def test_ensure_dir_0700_rejects_non_directory(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
f = tmp_path / "file.txt"
|
||||
f.write_text("hi")
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
ph_mod._ensure_dir_0700(f)
|
||||
|
||||
|
||||
def test_write_plist_refuses_symlink_parent(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
real_parent = tmp_path / "real"
|
||||
real_parent.mkdir()
|
||||
link_parent = tmp_path / "linkparent"
|
||||
link_parent.symlink_to(real_parent)
|
||||
plist_path = link_parent / "com.reyna.cli.privacy-host.plist"
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
ph_mod._write_plist_0600({"Label": "test"}, plist_path)
|
||||
|
||||
|
||||
def test_uid_tightening_returns_structured_error_not_throw(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
plist = tmp_path / "com.reyna.cli.privacy-host.plist"
|
||||
plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
|
||||
for bad_uid in ["not-int", "", " ", "1.5", True, -1, "abc"]:
|
||||
res = ph_mod.start_privacy_host_service(uid=bad_uid, plist_path=plist, runner=lambda *a, **k: None)
|
||||
assert isinstance(res, dict), f"should return dict for {bad_uid}"
|
||||
assert res.get("ok") is False, f"should be False for {bad_uid}: {res}"
|
||||
assert "error" in res or "invalid uid" in str(res).lower()
|
||||
|
||||
# Status also structured
|
||||
res_status = ph_mod.privacy_host_service_status(uid="bad-uid", plist_path_override=plist, socket_path_override=Path("/tmp/x.sock"))
|
||||
assert isinstance(res_status, dict)
|
||||
assert res_status.get("ok") is False
|
||||
assert "invalid uid" in str(res_status).lower()
|
||||
|
||||
|
||||
def test_uid_valid_int_string_coerced(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
plist = tmp_path / "com.reyna.cli.privacy-host.plist"
|
||||
plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
import plistlib as _pl
|
||||
_pl.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
def runner(args, **kwargs):
|
||||
if args and args[0] == "codesign":
|
||||
if "--verify" in args:
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return P()
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\n"
|
||||
return P()
|
||||
return Proc()
|
||||
calls.append(args)
|
||||
return Proc()
|
||||
|
||||
res = ph_mod.start_privacy_host_service(uid="501", plist_path=plist, runner=runner, repo_root=repo_root)
|
||||
assert res["ok"] is True
|
||||
assert calls[0] == ["launchctl", "kickstart", "-k", "gui/501/com.reyna.cli.privacy-host"]
|
||||
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
from reyna_cli.qwen_tts_direct import (
|
||||
JV_SAMPLE_TEXT,
|
||||
MODEL_ID,
|
||||
Qwen3TTSDirectClient,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_qwen_defaults_match_gitea_voiceagent():
|
||||
client = Qwen3TTSDirectClient()
|
||||
assert client.model_id == MODEL_ID
|
||||
assert client.model_id == "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
|
||||
assert client.ref_audio.name == "jv_voice_sample.wav"
|
||||
assert client.ref_text == JV_SAMPLE_TEXT
|
||||
assert client.instruct is None
|
||||
|
||||
|
||||
def test_qwen_config_is_offline_and_reports_jv_asset(tmp_path):
|
||||
ref_audio = tmp_path / "jv_voice_sample.wav"
|
||||
ref_audio.write_bytes(b"not-a-real-wav")
|
||||
client = Qwen3TTSDirectClient(ref_audio=ref_audio)
|
||||
|
||||
status = client.config_status()
|
||||
|
||||
assert status["source"] == "direct"
|
||||
assert status["engine"] == "qwen3-tts"
|
||||
assert status["model_id"] == MODEL_ID
|
||||
assert status["ref_audio"] == str(ref_audio)
|
||||
assert status["ref_audio_exists"] is True
|
||||
assert status["ref_text_configured"] is True
|
||||
assert status["offline"] is True
|
||||
|
||||
|
||||
def test_qwen_validation_rejects_missing_text_or_reference(tmp_path):
|
||||
client = Qwen3TTSDirectClient(ref_audio=tmp_path / "missing.wav")
|
||||
|
||||
with pytest.raises(ValueError, match="text required"):
|
||||
client.validate_generate("")
|
||||
with pytest.raises(FileNotFoundError, match="reference audio"):
|
||||
client.validate_generate("hello")
|
||||
|
||||
|
||||
def test_qwen_cli_is_registered_alongside_kokoro():
|
||||
result = runner.invoke(app, ["local-services", "qwen3-tts", "--help"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "generate" in result.output
|
||||
assert "config" in result.output
|
||||
|
||||
|
||||
def test_qwen_cli_exposes_instruct_prompt():
|
||||
result = runner.invoke(app, ["local-services", "qwen3-tts", "generate", "--help"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--instruct" in result.output
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Tests for current direct-Notes and native-host coverage boundaries.
|
||||
|
||||
The native privacy contract remains Notes-free. Apple Notes is intentionally a
|
||||
mutable Python CLI route, so it does not require rebuilding the Swift host.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# ─── Coverage matrix existence ───────────────────────────────────────────
|
||||
|
||||
def test_coverage_matrix_exists():
|
||||
from pathlib import Path
|
||||
p = Path(__file__).parents[1] / "docs" / "remaining-coverage-matrix.md"
|
||||
assert p.exists(), f"matrix doc missing at {p}"
|
||||
content = p.read_text()
|
||||
assert "direct mutable reyna cli" in content.lower()
|
||||
assert "fixed jxa" in content.lower()
|
||||
assert "signed bundle" in content.lower()
|
||||
|
||||
|
||||
# ─── Privacy contract — no notes, deferred ────────────────────────────────
|
||||
|
||||
def test_privacy_contract_no_notes_ops():
|
||||
from reyna_cli.privacy_contract import ALLOWED_OPERATIONS, _COMMAND_TO_OPERATION
|
||||
|
||||
for op in ALLOWED_OPERATIONS.keys():
|
||||
assert not op.startswith("notes."), f"forbidden notes op {op} present — Notes deferred"
|
||||
|
||||
forbidden = {"notes.list", "notes.read", "notes.create", "notes.request_access"}
|
||||
for fo in forbidden:
|
||||
assert fo not in ALLOWED_OPERATIONS
|
||||
|
||||
for cmd, op in _COMMAND_TO_OPERATION.items():
|
||||
assert not op.startswith("notes.")
|
||||
assert not cmd.startswith("notes_")
|
||||
|
||||
# Should still contain calendar/contacts/reminders/system/speech
|
||||
assert "calendar.list" in ALLOWED_OPERATIONS
|
||||
assert "contacts.search" in ALLOWED_OPERATIONS
|
||||
assert "reminders.lists" in ALLOWED_OPERATIONS
|
||||
assert "system.get_info" in ALLOWED_OPERATIONS
|
||||
assert "apple_llm.check" in ALLOWED_OPERATIONS
|
||||
|
||||
|
||||
def test_privacy_contract_mapping():
|
||||
from reyna_cli.privacy_contract import command_to_operation
|
||||
|
||||
assert command_to_operation("system_get_info") == "system.get_info"
|
||||
assert command_to_operation("apple_llm_check") == "apple_llm.check"
|
||||
|
||||
|
||||
# ─── System info wrappers still work ─────────────────────────────────────
|
||||
|
||||
def test_native_system_get_info_wrapper(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
assert op == "system.get_info"
|
||||
return {"id": "x", "ok": True, "result": {"system_info": {"macos_version": "26.0"}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
res = ph_mod.native_system_get_info()
|
||||
assert res["ok"] is True
|
||||
|
||||
|
||||
def test_cli_system_info_uses_native(monkeypatch):
|
||||
def fake_native():
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"system_info": {"macos_version": "15.0"}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_system_get_info", fake_native)
|
||||
result = runner.invoke(app, ["macmini", "system-info", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
# ─── Direct Notes CLI (outside the native privacy host) ───────────────────
|
||||
|
||||
def test_cli_macmini_notes_compatibility_subcommand():
|
||||
result = runner.invoke(app, ["macmini", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "notes" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_cli_privacy_host_no_notes_authorize():
|
||||
result = runner.invoke(app, ["privacy-host", "--help"])
|
||||
assert result.exit_code == 0
|
||||
# notes-authorize must be gone
|
||||
assert "notes" not in result.stdout.lower()
|
||||
|
||||
|
||||
# ─── Local services direct wrappers offline safe ───────────────────────────
|
||||
|
||||
def test_speech_direct_config_offline():
|
||||
from reyna_cli.local_services_direct import SpeechDirectClient
|
||||
|
||||
c = SpeechDirectClient().config_status()
|
||||
assert "say_available" in c
|
||||
assert "source" in c
|
||||
assert c["source"] == "direct"
|
||||
assert isinstance(c.get("say_path"), str)
|
||||
|
||||
|
||||
def test_speech_direct_validate_args():
|
||||
from reyna_cli.local_services_direct import SpeechDirectClient
|
||||
|
||||
cli = SpeechDirectClient()
|
||||
ok = cli.synthesize_args("hello", voice="Alex", rate=200)
|
||||
assert ok["text"] == "hello"
|
||||
with pytest.raises(ValueError):
|
||||
cli.synthesize_args("", voice="Alex")
|
||||
with pytest.raises(ValueError):
|
||||
cli.synthesize_args("hi", rate=10)
|
||||
|
||||
|
||||
def test_kokoro_config_offline_no_network():
|
||||
from reyna_cli.local_services_direct import KokoroDirectClient
|
||||
|
||||
c = KokoroDirectClient(url="http://127.0.0.1:7332").config_status()
|
||||
assert c["url"] == "http://127.0.0.1:7332"
|
||||
assert c["source"] == "direct"
|
||||
assert "note" in c
|
||||
v = KokoroDirectClient().validate_synthesize("hello world")
|
||||
assert v["offline_validation"] is True
|
||||
with pytest.raises(ValueError):
|
||||
KokoroDirectClient().validate_synthesize("")
|
||||
|
||||
|
||||
def test_kokoro_no_secret_exposure(monkeypatch):
|
||||
from reyna_cli.local_services_direct import KokoroDirectClient
|
||||
|
||||
monkeypatch.setenv("KSAY_URL", "http://127.0.0.1:7332")
|
||||
c = KokoroDirectClient().config_status()
|
||||
for k in c:
|
||||
assert "token" not in k.lower() or "password" not in str(c[k]).lower()
|
||||
|
||||
|
||||
def test_voicebox_config_offline():
|
||||
from reyna_cli.local_services_direct import VoiceboxDirectClient
|
||||
|
||||
c = VoiceboxDirectClient().config_status()
|
||||
assert "url" in c
|
||||
assert c["source"] == "direct"
|
||||
assert "known_profiles" in c
|
||||
v = VoiceboxDirectClient().validate_generate("hello", profile="Aiden")
|
||||
assert v["offline_validation"] is True
|
||||
|
||||
|
||||
def test_apple_llm_config_offline():
|
||||
from reyna_cli.local_services_direct import AppleLLMDirectClient
|
||||
|
||||
c = AppleLLMDirectClient().config_status()
|
||||
assert "swift_available" in c or "swiftc_available" in c
|
||||
assert c["source"] == "direct"
|
||||
v = AppleLLMDirectClient().validate_polish("hello world", mode="line")
|
||||
assert v["offline_validation"] is True
|
||||
|
||||
|
||||
def test_image_config_offline_no_key_exposure(monkeypatch):
|
||||
from reyna_cli.local_services_direct import ImageDirectClient
|
||||
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
c = ImageDirectClient().config_status()
|
||||
assert c["source"] == "direct"
|
||||
assert "gemini_api_key_configured" in c
|
||||
assert c["gemini_api_key_configured"] is False
|
||||
assert "GEMINI_API_KEY" not in json.dumps(c)
|
||||
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "secret123")
|
||||
c2 = ImageDirectClient().config_status()
|
||||
assert c2["gemini_api_key_configured"] is True
|
||||
assert "secret123" not in json.dumps(c2)
|
||||
|
||||
|
||||
def test_system_direct_offline():
|
||||
from reyna_cli.local_services_direct import SystemDirectClient
|
||||
|
||||
c = SystemDirectClient().config_status()
|
||||
assert c["requires_tcc"] is False
|
||||
info = SystemDirectClient().get_info_offline()
|
||||
assert "macos_version" in info
|
||||
|
||||
|
||||
# ─── CLI local-services commands offline ──────────────────────────────────
|
||||
|
||||
def test_cli_local_services_speech_config():
|
||||
result = runner.invoke(app, ["local-services", "speech", "config", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["source"] == "direct"
|
||||
|
||||
|
||||
def test_cli_local_services_kokoro_config():
|
||||
result = runner.invoke(app, ["local-services", "kokoro", "config", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["result"]["source"] == "direct"
|
||||
|
||||
|
||||
def test_cli_local_services_voicebox_config():
|
||||
result = runner.invoke(app, ["local-services", "voicebox", "config", "--json"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_cli_local_services_apple_llm_config():
|
||||
result = runner.invoke(app, ["local-services", "apple-llm", "config", "--json"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_cli_local_services_image_config():
|
||||
result = runner.invoke(app, ["local-services", "image", "config", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_cli_local_services_system_info():
|
||||
result = runner.invoke(app, ["local-services", "system", "info", "--json"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_no_mcp_imports_in_direct_wrappers():
|
||||
from pathlib import Path
|
||||
|
||||
p = Path(__file__).parents[1] / "src" / "reyna_cli" / "local_services_direct.py"
|
||||
src = p.read_text()
|
||||
assert "MCPClient" not in src
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "macmini_client" not in src
|
||||
assert "httpx.Client" not in src
|
||||
assert "requests.get" not in src
|
||||
|
||||
|
||||
def test_direct_wrappers_no_credential_exposure():
|
||||
from pathlib import Path
|
||||
|
||||
src = (Path(__file__).parents[1] / "src" / "reyna_cli" / "local_services_direct.py").read_text()
|
||||
assert "DECO_PASSWORD" not in src
|
||||
from reyna_cli.local_services_direct import KokoroDirectClient, VoiceboxDirectClient, AppleLLMDirectClient
|
||||
|
||||
for c in [KokoroDirectClient().config_status(), VoiceboxDirectClient().config_status(), AppleLLMDirectClient().config_status()]:
|
||||
for k, v in c.items():
|
||||
if isinstance(v, str):
|
||||
assert len(v) < 5000
|
||||
|
||||
|
||||
def test_no_notes_wrappers_in_privacy_host():
|
||||
from pathlib import Path
|
||||
|
||||
src = (Path(__file__).parents[1] / "src" / "reyna_cli" / "privacy_host.py").read_text()
|
||||
assert "native_notes" not in src
|
||||
assert "NotesProvider" not in src
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Tests for reminders native wrappers and CLI – TDD fakes only, no live Reminders access."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_native_reminders_request_full_access_explicit_op(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout):
|
||||
captured["timeout"] = timeout
|
||||
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "x", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "reminders.request_full_access", "status": "authorized"}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_reminders_request_full_access()
|
||||
assert captured["op"] == "reminders.request_full_access"
|
||||
assert captured["args"] == {}
|
||||
assert captured["timeout"] == 35
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["status"] == "authorized"
|
||||
|
||||
|
||||
def test_native_reminders_lists_direct_op(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "1", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "reminders.lists", "reminder_lists": [{"id": "a", "title": "Groceries", "source": "iCloud", "type": "caldav"}]}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_reminders_lists()
|
||||
assert captured["op"] == "reminders.lists"
|
||||
assert captured["args"] == {}
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["reminder_lists"][0]["title"] == "Groceries"
|
||||
|
||||
|
||||
def test_native_reminders_list_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "2", "ok": True, "result": {"reminders": [{"id": "r1", "list_id": "a", "list_title": "Groceries", "title": "Milk", "completed": False, "due": None, "priority": 0}]}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_reminders_list(list_name="Groceries", limit=25)
|
||||
assert captured["op"] == "reminders.list"
|
||||
assert captured["args"]["list"] == "Groceries"
|
||||
assert captured["args"]["limit"] == 25
|
||||
assert result["result"]["reminders"][0]["title"] == "Milk"
|
||||
|
||||
|
||||
def test_native_reminders_list_with_id_and_completed_filter(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["args"] = args
|
||||
return {"id": "2", "ok": True, "result": {"reminders": []}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
ph_mod.native_reminders_list(list_id="stable-id", completed=True, limit=50)
|
||||
assert captured["args"]["list_id"] == "stable-id"
|
||||
assert captured["args"]["completed"] is True
|
||||
assert captured["args"]["limit"] == 50
|
||||
|
||||
|
||||
def test_native_reminders_create_success_with_list_title(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "3", "ok": True, "result": {"created_reminder": {"id": "new", "list_id": "a", "list_title": "Groceries", "title": "Buy eggs"}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_reminders_create(title="Buy eggs", list_name="Groceries", notes="organic", due="2026-08-10T10:00:00Z", priority=1)
|
||||
assert captured["op"] == "reminders.create"
|
||||
assert captured["args"]["title"] == "Buy eggs"
|
||||
assert captured["args"]["list"] == "Groceries"
|
||||
assert captured["args"]["notes"] == "organic"
|
||||
assert captured["args"]["due"] == "2026-08-10T10:00:00Z"
|
||||
assert captured["args"]["priority"] == 1
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_native_reminders_create_requires_list():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
with pytest.raises(ValueError, match="list must be specified"):
|
||||
ph_mod.native_reminders_create(title="No list")
|
||||
|
||||
|
||||
def test_native_reminders_create_validates_title():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
with pytest.raises(ValueError, match="title must be nonempty"):
|
||||
ph_mod.native_reminders_create(title="", list_name="X")
|
||||
|
||||
with pytest.raises(ValueError, match="title exceeds"):
|
||||
ph_mod.native_reminders_create(title="A" * 1025, list_name="X")
|
||||
|
||||
|
||||
def test_native_reminders_create_validates_limit():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
with pytest.raises(ValueError, match="limit"):
|
||||
ph_mod.native_reminders_list(limit=0)
|
||||
|
||||
with pytest.raises(ValueError, match="limit"):
|
||||
ph_mod.native_reminders_list(limit=999)
|
||||
|
||||
|
||||
def test_native_reminders_no_mcp_import():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
import pathlib
|
||||
src = pathlib.Path(ph_mod.__file__).read_text()
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "macmini_client" not in src
|
||||
assert "def native_reminders_request_full_access" in src
|
||||
assert "reminders.request_full_access" in src
|
||||
assert "def native_reminders_lists" in src
|
||||
assert "def native_reminders_list" in src
|
||||
assert "def native_reminders_create" in src
|
||||
|
||||
|
||||
def test_cli_reminders_lists_uses_native(monkeypatch):
|
||||
def fake_lists():
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"reminder_lists": [{"id": "a", "title": "Groceries", "source": "iCloud", "type": "caldav"}]}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_lists", fake_lists)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "lists", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["result"]["reminder_lists"][0]["title"] == "Groceries"
|
||||
|
||||
|
||||
def test_cli_reminders_list_uses_native(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_list(list_id=None, list_name=None, completed=None, limit=50):
|
||||
captured["list_id"] = list_id
|
||||
captured["list_name"] = list_name
|
||||
captured["completed"] = completed
|
||||
captured["limit"] = limit
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"reminders": []}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_list", fake_list)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "list", "--list", "Groceries", "--limit", "10", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["list_name"] == "Groceries"
|
||||
assert captured["limit"] == 10
|
||||
|
||||
|
||||
def test_cli_reminders_list_with_completed_flags(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_list(list_id=None, list_name=None, completed=None, limit=25):
|
||||
captured["completed"] = completed
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"reminders": []}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_list", fake_list)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "list", "--list", "Groceries", "--completed", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["completed"] is True
|
||||
|
||||
res2 = runner.invoke(app, ["macmini", "reminders", "list", "--list", "Groceries", "--incomplete", "--json"])
|
||||
assert res2.exit_code == 0, res2.stdout + res2.stderr
|
||||
# --incomplete should set completed=False
|
||||
assert captured["completed"] is False
|
||||
|
||||
|
||||
def test_cli_reminders_create_requires_list():
|
||||
res = runner.invoke(app, ["macmini", "reminders", "create", "Buy milk", "--json"])
|
||||
assert res.exit_code != 0
|
||||
|
||||
|
||||
def test_cli_reminders_create_with_title_and_list(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_create(title, list_id=None, list_name=None, notes=None, due=None, priority=None):
|
||||
captured["title"] = title
|
||||
captured["list_name"] = list_name
|
||||
captured["list_id"] = list_id
|
||||
captured["notes"] = notes
|
||||
captured["due"] = due
|
||||
captured["priority"] = priority
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"created_reminder": {"id": "new", "list_id": "a", "list_title": "Groceries", "title": title}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_create", fake_create)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "create", "Buy milk", "--list", "Groceries", "--notes", "2% please", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["title"] == "Buy milk"
|
||||
assert captured["list_name"] == "Groceries"
|
||||
assert captured["notes"] == "2% please"
|
||||
|
||||
|
||||
def test_cli_reminders_create_with_list_id_and_due_priority(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_create(title, list_id=None, list_name=None, notes=None, due=None, priority=None):
|
||||
captured["list_id"] = list_id
|
||||
captured["due"] = due
|
||||
captured["priority"] = priority
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"created_reminder": {"id": "new", "list_id": list_id or "", "list_title": "", "title": title}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_create", fake_create)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "create", "Task", "--list-id", "abc-123", "--due", "2026-08-10T10:00:00Z", "--priority", "1", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["list_id"] == "abc-123"
|
||||
assert captured["due"] == "2026-08-10T10:00:00Z"
|
||||
assert captured["priority"] == 1
|
||||
|
||||
|
||||
def test_cli_reminders_no_mcp_tool_call_remaining():
|
||||
from reyna_cli import cli as cli_mod
|
||||
src = Path(cli_mod.__file__).read_text()
|
||||
assert "native_reminders_lists" in src
|
||||
assert "native_reminders_list" in src
|
||||
assert "native_reminders_create" in src
|
||||
assert 'call_macmini_tool("reminders_list_lists"' not in src
|
||||
assert 'call_macmini_tool("reminders_list"' not in src
|
||||
assert 'call_macmini_tool("reminders_create"' not in src
|
||||
|
||||
|
||||
def test_cli_reminders_edit_uses_remindctl(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_run(args):
|
||||
captured["args"] = args
|
||||
return {"updated": {"id": "r1", "title": "New title"}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.cli.run_remindctl", fake_run)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "edit", "r1", "--title", "New title", "--due", "tomorrow", "--json"])
|
||||
|
||||
assert res.exit_code == 0
|
||||
assert captured["args"] == ["edit", "r1", "--title", "New title", "--due", "tomorrow", "--json", "--no-input"]
|
||||
assert json.loads(res.stdout)["source"] == "remindctl"
|
||||
|
||||
|
||||
def test_cli_reminders_delete_requires_force(monkeypatch):
|
||||
res = runner.invoke(app, ["macmini", "reminders", "delete", "r1", "--json"])
|
||||
|
||||
assert res.exit_code == 1
|
||||
assert "--force" in res.stdout
|
||||
|
||||
|
||||
def test_cli_reminders_delete_uses_remindctl_with_force(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_run(args):
|
||||
captured["args"] = args
|
||||
return {"deleted": ["r1"]}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.cli.run_remindctl", fake_run)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "delete", "r1", "--force", "--json"])
|
||||
|
||||
assert res.exit_code == 0
|
||||
assert captured["args"] == ["delete", "r1", "--force", "--json", "--no-input"]
|
||||
|
||||
|
||||
def test_privacy_host_cli_has_reminders_authorize():
|
||||
res = runner.invoke(app, ["privacy-host", "--help"])
|
||||
assert res.exit_code == 0
|
||||
assert "reminders-authorize" in res.stdout
|
||||
|
||||
|
||||
def test_privacy_host_reminders_authorize_cli_help_mentions_prompt():
|
||||
res = runner.invoke(app, ["privacy-host", "reminders-authorize", "--help"])
|
||||
assert res.exit_code == 0
|
||||
out = res.stdout.lower()
|
||||
assert "reminders" in out
|
||||
assert "permission" in out or "prompt" in out or "privacy" in out
|
||||
|
||||
|
||||
def test_reminders_authorize_cli_no_generic_fallback(monkeypatch):
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_native():
|
||||
calls["count"] += 1
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"protocol_version": "1.0.0", "operation": "reminders.request_full_access", "status": "authorized"}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_request_full_access", fake_native)
|
||||
|
||||
res = runner.invoke(app, ["privacy-host", "reminders-authorize", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["result"]["status"] == "authorized"
|
||||
assert calls["count"] == 1
|
||||
@@ -164,14 +164,6 @@ def test_devices_laptop_has_subcommands():
|
||||
assert "battery" in result.stdout
|
||||
|
||||
|
||||
def test_devices_arm_has_subcommands():
|
||||
result = runner.invoke(app, ["devices", "arm", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "state" in result.stdout
|
||||
assert "wave" in result.stdout
|
||||
assert "home" in result.stdout
|
||||
|
||||
|
||||
def test_immich_has_subcommands():
|
||||
result = runner.invoke(app, ["immich", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""TDD: builder must use -scheme and derivedDataPath, not -target incompatible form."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
def test_builder_uses_scheme_and_derived_data_path():
|
||||
from reyna_cli import app_bundle as ab
|
||||
repo_root = Path(tempfile.mkdtemp()) / "repo"
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "custom" / "DerivedData"
|
||||
|
||||
cmd = ab.build_xcodebuild_command(repo_root=repo_root, derived_data_path=derived, disable_code_signing=True)
|
||||
|
||||
# Must use -scheme with valid scheme name
|
||||
assert "-scheme" in cmd, f"expected -scheme in {cmd}, got {cmd}"
|
||||
scheme_idx = cmd.index("-scheme")
|
||||
assert cmd[scheme_idx + 1] == "Reyna CLI", f"scheme name mismatch {cmd}"
|
||||
|
||||
# Must have derivedDataPath
|
||||
assert "-derivedDataPath" in cmd
|
||||
dd_idx = cmd.index("-derivedDataPath")
|
||||
assert cmd[dd_idx + 1] == str(derived)
|
||||
|
||||
# Must NOT use -target with -derivedDataPath (invalid, RC 64)
|
||||
assert "-target" not in cmd, f"must not use -target when using -derivedDataPath, got {cmd}"
|
||||
|
||||
# Must still contain configuration Release and build verb
|
||||
assert "-configuration" in cmd
|
||||
assert "Release" in cmd
|
||||
assert "build" in cmd
|
||||
assert "CODE_SIGNING_ALLOWED=NO" in cmd
|
||||
|
||||
def test_builder_unsigned_variant_still_uses_scheme():
|
||||
from reyna_cli import app_bundle as ab
|
||||
repo_root = Path(tempfile.mkdtemp()) / "repo"
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "custom" / "DerivedData"
|
||||
|
||||
cmd_signed = ab.build_xcodebuild_command(repo_root=repo_root, derived_data_path=derived, disable_code_signing=False)
|
||||
assert "-scheme" in cmd_signed
|
||||
assert "-target" not in cmd_signed
|
||||
@@ -0,0 +1,70 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_run_signed_python_invokes_bundle_with_python_and_forwards_args(tmp_path):
|
||||
from reyna_cli.signed_launcher import run_signed_python
|
||||
|
||||
executable = tmp_path / "ReynaCLIHost"
|
||||
executable.write_text("binary")
|
||||
calls = []
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return SimpleNamespace(returncode=17)
|
||||
|
||||
result = run_signed_python(["local-services", "speech", "generate", "hello"], executable=executable, runner=fake_run)
|
||||
|
||||
assert result == 17
|
||||
assert calls == [(
|
||||
[str(executable), "--python", "local-services", "speech", "generate", "hello"],
|
||||
{"check": False},
|
||||
)]
|
||||
|
||||
|
||||
def test_run_signed_python_rejects_missing_bundle_executable(tmp_path):
|
||||
from reyna_cli.signed_launcher import SignedLauncherError, run_signed_python
|
||||
|
||||
try:
|
||||
run_signed_python(["doctor"], executable=tmp_path / "missing")
|
||||
except SignedLauncherError as exc:
|
||||
assert "signed Reyna CLI app executable" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing signed executable must fail")
|
||||
|
||||
|
||||
def test_signed_command_forwards_unknown_arguments(monkeypatch):
|
||||
from reyna_cli import signed_launcher
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_run(args):
|
||||
captured["args"] = args
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(signed_launcher, "run_signed_python", fake_run)
|
||||
|
||||
result = runner.invoke(app, ["signed", "local-services", "speech", "generate", "hello", "--voice", "Alex"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured["args"] == ["local-services", "speech", "generate", "hello", "--voice", "Alex"]
|
||||
|
||||
|
||||
def test_signed_command_reports_missing_signed_bundle(monkeypatch):
|
||||
from reyna_cli import signed_launcher
|
||||
|
||||
def fake_run(args):
|
||||
raise signed_launcher.SignedLauncherError("signed Reyna CLI app executable is missing")
|
||||
|
||||
monkeypatch.setattr(signed_launcher, "run_signed_python", fake_run)
|
||||
|
||||
result = runner.invoke(app, ["signed", "doctor"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "signed Reyna CLI app executable is missing" in result.stdout
|
||||
@@ -0,0 +1,151 @@
|
||||
"""TDD: SystemInfoProvider must be linked in Xcode project and compile full protocol.
|
||||
|
||||
RED: before fix, project.pbxproj lacks SystemInfoProvider.swift -> should fail.
|
||||
GREEN: after adding fileRef, group, and Sources entries, passes.
|
||||
|
||||
Also regression: unsigned Xcode shared scheme Release build must succeed.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PBX = REPO_ROOT / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj"
|
||||
SYSTEM_PROVIDER_SWIFT = REPO_ROOT / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "SystemInfoProvider.swift"
|
||||
PROTOCOL_SWIFT = REPO_ROOT / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "Protocol.swift"
|
||||
|
||||
|
||||
def _read_pbx() -> str:
|
||||
assert PBX.exists(), f"project.pbxproj missing at {PBX}"
|
||||
return PBX.read_text()
|
||||
|
||||
|
||||
def test_system_info_provider_file_exists():
|
||||
assert SYSTEM_PROVIDER_SWIFT.exists(), f"SystemInfoProvider.swift missing at {SYSTEM_PROVIDER_SWIFT}"
|
||||
src = SYSTEM_PROVIDER_SWIFT.read_text()
|
||||
assert "SystemInfoItem" in src
|
||||
assert "SpeechApiStatusItem" in src
|
||||
assert "ProductionSystemInfoProvider" in src
|
||||
# Conformance required by ResultPayload (Codable, Equatable, Sendable)
|
||||
assert "Codable, Equatable, Sendable" in src or ("Codable" in src and "Equatable" in src and "Sendable" in src)
|
||||
|
||||
|
||||
def test_system_info_result_types_are_codable_equatable_sendable():
|
||||
src = SYSTEM_PROVIDER_SWIFT.read_text()
|
||||
# Both data models must be Codable, Equatable, Sendable
|
||||
assert "struct SystemInfoItem: Codable, Equatable, Sendable" in src
|
||||
assert "struct SpeechApiStatusItem: Codable, Equatable, Sendable" in src
|
||||
# No secret leak: fields must be config/status only
|
||||
forbidden = ["password", "token", "api_key", "secret", "bundle_seed", "keychain", "credential"]
|
||||
lower = src.lower()
|
||||
for word in forbidden:
|
||||
# Allow if in comment about not leaking? But our file should not contain at all except maybe password_configured? Check we don't have password in this file
|
||||
# For system info, none of these should appear
|
||||
assert word not in lower or word == "token" and False, f"SystemInfoProvider must not contain sensitive field {word}" # noqa
|
||||
# Actually check explicitly: file should not contain password/token/api_key
|
||||
assert "password" not in lower
|
||||
assert "api_key" not in lower
|
||||
assert "secret" not in lower
|
||||
|
||||
|
||||
def test_xcode_pbx_contains_system_info_provider_ref_and_buildfile_and_group_and_sources():
|
||||
pbx = _read_pbx()
|
||||
# File ref
|
||||
assert "SystemInfoProvider.swift" in pbx, "SystemInfoProvider.swift missing from pbxproj file refs"
|
||||
# Build file entry
|
||||
assert "SystemInfoProvider.swift in Sources" in pbx, "SystemInfoProvider.swift missing from Sources build phase"
|
||||
# Core group must contain it (ReynaCLIHostCore group children includes SystemInfoProvider)
|
||||
# Look for group section
|
||||
assert "ReynaCLIHostCore" in pbx
|
||||
# The pbx structure: core group lists all swift files; we already check presence but also ensure PBXBuildFile entry exists
|
||||
assert "PBXBuildFile" in pbx
|
||||
assert "SystemInfoProvider.swift" in pbx.split("/* Begin PBXFileReference section */")[1].split("/* End PBXFileReference section */")[0] or "SystemInfoProvider.swift" in pbx
|
||||
|
||||
|
||||
def test_protocol_references_system_info_types_match_provider():
|
||||
proto = PROTOCOL_SWIFT.read_text()
|
||||
# Protocol must reference system.get_info, system.speech_api_status, apple_llm.check
|
||||
assert "system.get_info" in proto
|
||||
assert "system.speech_api_status" in proto
|
||||
assert "apple_llm.check" in proto
|
||||
# ResultPayload must have system_info and speech_api_status
|
||||
assert "system_info" in proto
|
||||
assert "speech_api_status" in proto
|
||||
assert "SystemInfoItem" in proto
|
||||
assert "SpeechApiStatusItem" in proto
|
||||
# Ensure apple_llm.check does NOT require private frameworks - it should be status ok only
|
||||
# Find its case
|
||||
assert 'case "apple_llm.check"' in proto
|
||||
|
||||
|
||||
def test_xcode_project_protocol_version_result_payload_extended_still_codable():
|
||||
# Simulate Codable check via swiftc compilation of Protocol.swift + SystemInfoProvider.swift alone
|
||||
# More importantly, ensure ResultPayload includes only expected ops and remains Codable
|
||||
proto = PROTOCOL_SWIFT.read_text()
|
||||
# Ensure ResultPayload init includes system_info and speech_api_status params
|
||||
assert "system_info: SystemInfoItem? = nil" in proto
|
||||
assert "speech_api_status: SpeechApiStatusItem? = nil" in proto
|
||||
|
||||
|
||||
def test_xcode_references_no_duplicate_or_missing_system_file_ref_ids():
|
||||
pbx = _read_pbx()
|
||||
# Count occurrences
|
||||
assert pbx.count("SystemInfoProvider.swift") >= 3, "Expected at least fileRef + buildFile + group entries"
|
||||
# Ensure IDs are present (B.. for file ref, C.. for build file)
|
||||
assert "B00000000000000000000015" in pbx or "SystemInfoProvider.swift\" = {isa = PBXFileReference" in pbx
|
||||
|
||||
|
||||
def test_apple_llm_check_belongs_in_native_app_and_uses_no_private_frameworks():
|
||||
proto = PROTOCOL_SWIFT.read_text()
|
||||
system_src = SYSTEM_PROVIDER_SWIFT.read_text()
|
||||
# apple_llm.check must NOT import FoundationModels private or unsupported
|
||||
assert "FoundationModels" not in proto
|
||||
assert "FoundationModels" not in system_src or "framework" not in system_src.lower() or True # allowed in comment but not import
|
||||
# Must NOT import Speech private only, etc. SystemInfoProvider should only use Foundation/Darwin
|
||||
assert "import Foundation" in system_src
|
||||
# Ensure apple_llm.check path returns status ok, failclosed if error
|
||||
# Extract the case block
|
||||
idx = proto.find('case "apple_llm.check"')
|
||||
assert idx != -1
|
||||
block = proto[idx: idx + 600]
|
||||
assert "status" in block
|
||||
assert "ok" in block
|
||||
# No force unwrap of private framework symbols
|
||||
assert "SystemLanguageModel" not in proto
|
||||
assert "ANE" not in proto or "ANE" in proto and ("conclusion" in proto.lower() or True) # ANE only in comments or SystemInfoProvider's framework string is allowed elsewhere but not in Protocol.swift operation?
|
||||
# Actually Protocol.swift should not reference ANE 3B classes directly
|
||||
assert "LanguageModelSession" not in proto
|
||||
|
||||
|
||||
def test_system_info_privacy_no_sensitive_config_leak():
|
||||
"""System info must only expose config/status, no sensitive system config like passwords."""
|
||||
src = SYSTEM_PROVIDER_SWIFT.read_text()
|
||||
# Allowed fields per task: config/status commands only, no secret system config
|
||||
# Check struct fields are whitelisted
|
||||
allowed_system_fields = {"macos_version", "build", "uname", "hw_model", "cpu_brand", "is_macos_26_plus", "speech_analyzer_expected"}
|
||||
allowed_speech_fields = {"system", "swift_availability", "conclusion"}
|
||||
# Extract struct definitions
|
||||
# Simple check: ensure struct contains only allowed fields (parse lines)
|
||||
system_block = src[src.find("struct SystemInfoItem"): src.find("struct SystemInfoItem") + 600]
|
||||
for forbidden in ["password", "token", "secret", "keychain", "home_directory", "user_home", "env"]:
|
||||
assert forbidden not in system_block.lower(), f"forbidden field {forbidden} in SystemInfoItem"
|
||||
|
||||
|
||||
def test_xcode_unsigned_release_build_smoke():
|
||||
"""Regression: unsigned Xcode shared scheme Release build must succeed (full protocol)."""
|
||||
from reyna_cli import app_bundle as ab
|
||||
repo_root = REPO_ROOT
|
||||
# Build with CODE_SIGNING_ALLOWED=NO, like in test_app_bundle_unsigned
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
# Run xcodebuild command via app_bundle helper to ensure shared scheme
|
||||
cmd = ab.build_xcodebuild_command(repo_root=repo_root, derived_data_path=derived, disable_code_signing=True)
|
||||
# Ensure our new file is not causing compile failure in the command list sense
|
||||
assert "SystemInfoProvider" not in " ".join(cmd) # command doesn't need to mention file, but xcode project does
|
||||
# Actually run xcodebuild
|
||||
proc = subprocess.run(cmd, cwd=str(repo_root), capture_output=True, text=True, timeout=180)
|
||||
assert proc.returncode == 0, f"xcodebuild unsigned Release failed: {proc.stdout[-2000:]} {proc.stderr[-2000:]}"
|
||||
# Verify product exists
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app" / "Contents" / "MacOS" / "ReynaCLIHost"
|
||||
assert built_app.exists(), f"built product missing at {built_app}"
|
||||
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
from reyna_cli.config import Device, Registry, get_device, resolve_device_host
|
||||
from reyna_cli.tactility import TactilityClient
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_friendly_name_lookup_is_case_insensitive():
|
||||
registry = Registry(devices=[Device(id="kidsos_5c5c", display_name="Grace's 2.8-inch Tactility Board")])
|
||||
assert get_device("Grace", registry).id == "kidsos_5c5c"
|
||||
assert get_device("grace", registry).id == "kidsos_5c5c"
|
||||
|
||||
|
||||
def test_resolve_device_host_prefers_live_mdns(monkeypatch):
|
||||
monkeypatch.setattr("reyna_cli.config.socket.gethostbyname", lambda host: "192.168.68.141")
|
||||
device = Device(id="kidsos_1234", host="kidsos-1234.local", reserved_ip="192.168.68.99")
|
||||
assert resolve_device_host(device) == "192.168.68.141"
|
||||
|
||||
|
||||
def test_resolve_device_host_falls_back_when_mdns_is_offline(monkeypatch):
|
||||
def fail(_host):
|
||||
raise OSError("offline")
|
||||
|
||||
monkeypatch.setattr("reyna_cli.config.socket.gethostbyname", fail)
|
||||
device = Device(id="kidsos_1234", host="kidsos-1234.local", reserved_ip="192.168.68.99")
|
||||
assert resolve_device_host(device) == "192.168.68.99"
|
||||
|
||||
|
||||
def test_tactility_client_uses_web_api(monkeypatch, tmp_path):
|
||||
requests = []
|
||||
|
||||
def handler(request: httpx.Request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/sysinfo":
|
||||
return httpx.Response(200, json={"version": "0.8.0-dev"})
|
||||
if request.url.path == "/api/apps":
|
||||
return httpx.Response(200, json={"apps": [{"id": "one.tactility.demo"}]})
|
||||
if request.url.path == "/fs/list":
|
||||
return httpx.Response(200, json={"path": "/sdcard", "entries": []})
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
client = TactilityClient("http://kidsos-1234.local", transport=httpx.MockTransport(handler))
|
||||
assert client.sysinfo()["version"] == "0.8.0-dev"
|
||||
assert client.apps()["apps"][0]["id"] == "one.tactility.demo"
|
||||
assert client.fs_list("/sdcard")["path"] == "/sdcard"
|
||||
assert requests[0].url.host == "kidsos-1234.local"
|
||||
|
||||
|
||||
def test_tactility_install_and_run_requests(tmp_path):
|
||||
requests = []
|
||||
|
||||
def handler(request: httpx.Request):
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
app_file = tmp_path / "demo.app"
|
||||
app_file.write_bytes(b"APP")
|
||||
client = TactilityClient("http://192.168.68.99", transport=httpx.MockTransport(handler))
|
||||
client.install_app(app_file)
|
||||
client.run_app("one.tactility.demo")
|
||||
|
||||
assert requests[0].method == "PUT"
|
||||
assert requests[0].url.path == "/api/apps/install"
|
||||
assert requests[1].url.path == "/api/apps/run"
|
||||
assert requests[1].url.params["id"] == "one.tactility.demo"
|
||||
|
||||
|
||||
def test_tactility_screen_text_clears_before_write():
|
||||
requests = []
|
||||
|
||||
def handler(request: httpx.Request):
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
client = TactilityClient("http://192.168.68.99", transport=httpx.MockTransport(handler))
|
||||
result = client.screen_text("Grace", 20, 10, clear_first=True)
|
||||
|
||||
assert result["clear_first"] is True
|
||||
assert [request.url.path for request in requests] == ["/api/screen/raw", "/api/screen/raw"]
|
||||
assert requests[0].content == bytes(20 * 10 * 2)
|
||||
assert len(requests[1].content) == 20 * 10 * 2
|
||||
|
||||
|
||||
def test_tactility_cli_has_commands(monkeypatch, tmp_path):
|
||||
registry_path = tmp_path / "devices.yaml"
|
||||
registry_path.write_text(
|
||||
"""
|
||||
devices:
|
||||
kidsos_1234:
|
||||
type: esp32-s3-tactility
|
||||
host: kidsos-1234.local
|
||||
reserved_ip: 192.168.68.99
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("REYNA_DEVICES_REGISTRY", str(registry_path))
|
||||
|
||||
result = runner.invoke(app, ["tactility", "--help"])
|
||||
assert result.exit_code == 0
|
||||
for command in ("discover", "sysinfo", "apps", "install", "run", "report", "fs", "screen"):
|
||||
assert command in result.stdout
|
||||
|
||||
|
||||
def test_robot_arm_is_not_a_cli_surface():
|
||||
result = runner.invoke(app, ["devices", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "arm" not in result.stdout.lower()
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
from reyna_cli.voice_direct import PocketTTSDirectClient, UnifiedVoiceClient
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_unified_voice_config_keeps_three_named_engines_separate(tmp_path):
|
||||
status = UnifiedVoiceClient(pocket_voice_state=tmp_path / "jv_pocket.pt").config_status()
|
||||
|
||||
assert set(status["engines"]) == {"kokoro", "pocket", "qwen3-tts"}
|
||||
assert status["engines"]["pocket"]["voice_state"] == str(tmp_path / "jv_pocket.pt")
|
||||
assert status["engines"]["qwen3-tts"]["model_id"] == "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
|
||||
|
||||
|
||||
def test_pocket_validation_requires_exact_custom_state(tmp_path):
|
||||
client = PocketTTSDirectClient(voice_state=tmp_path / "missing.pt")
|
||||
|
||||
try:
|
||||
client.validate_generate("hello")
|
||||
except FileNotFoundError as exc:
|
||||
assert "Pocket voice state" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing Pocket state must not silently fall back")
|
||||
|
||||
|
||||
def test_unified_voice_cli_exposes_explicit_engine_selection():
|
||||
result = runner.invoke(app, ["local-services", "voice", "generate", "--help"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--engine" in result.output
|
||||
assert "kokoro" in result.output
|
||||
assert "pocket" in result.output
|
||||
assert "qwen3-tts" in result.output
|
||||
|
||||
|
||||
def test_unified_voice_config_cli_is_offline(monkeypatch):
|
||||
result = runner.invoke(app, ["local-services", "voice", "config", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert set(json.loads(result.stdout)["result"]["engines"]) == {"kokoro", "pocket", "qwen3-tts"}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Static contract: project must not force ad-hoc CODE_SIGN_IDENTITY when using Automatic Signing."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PBX = REPO_ROOT / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj"
|
||||
|
||||
|
||||
def _read_pbx() -> str:
|
||||
assert PBX.exists(), f"project.pbxproj missing at {PBX}"
|
||||
return PBX.read_text()
|
||||
|
||||
|
||||
def test_no_forced_adhoc_code_sign_identity():
|
||||
src = _read_pbx()
|
||||
# Fail if any CODE_SIGN_IDENTITY variant is forced to "-" or ad-hoc
|
||||
# Covers CODE_SIGN_IDENTITY and CODE_SIGN_IDENTITY[sdk=...]
|
||||
pattern = re.compile(r'CODE_SIGN_IDENTITY.*?=\s*"?-"?\s*;', re.IGNORECASE)
|
||||
matches = pattern.findall(src)
|
||||
assert not matches, f"found forced ad-hoc CODE_SIGN_IDENTITY: {matches} in {PBX}"
|
||||
|
||||
# Also explicitly check literal '"-"'
|
||||
assert '"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"' not in src
|
||||
assert 'CODE_SIGN_IDENTITY = "-"' not in src
|
||||
assert 'CODE_SIGN_IDENTITY = -' not in src
|
||||
|
||||
|
||||
def test_automatic_signing_not_paired_with_forced_identity():
|
||||
src = _read_pbx()
|
||||
# If project uses CODE_SIGN_STYLE = Automatic, it must not also force CODE_SIGN_IDENTITY to ad-hoc
|
||||
assert "CODE_SIGN_STYLE = Automatic" in src, "expected CODE_SIGN_STYLE=Automatic for durable identity"
|
||||
# Scan buildSettings blocks containing Automatic - simplistic but effective
|
||||
# Any occurrence of CODE_SIGN_IDENTITY with "-" while Automatic present is violation
|
||||
has_adhoc = bool(re.search(r'CODE_SIGN_IDENTITY.*=\s*"?-"?\s*;', src))
|
||||
has_auto = "CODE_SIGN_STYLE = Automatic" in src
|
||||
assert not (has_auto and has_adhoc), (
|
||||
"Automatic Signing paired with forced CODE_SIGN_IDENTITY=\"-\" defeats team signing; "
|
||||
"remove forced identity so Xcode can use selected team"
|
||||
)
|
||||
|
||||
|
||||
def test_static_config_bundle_and_signing_style():
|
||||
src = _read_pbx()
|
||||
assert "com.reyna.cli.privacy-host" in src, "bundle ID must remain fixed"
|
||||
assert "CODE_SIGN_STYLE = Automatic" in src
|
||||
# Must not contain literal manual style when we expect automatic
|
||||
# Ensure bundle id still present and no ad-hoc marker left
|
||||
assert '"-" ' not in src or 'CODE_SIGN_IDENTITY' not in src.split('"-"')[0][-100:] # sanity
|
||||
# Double-check no CODE_SIGN_IDENTITY forced at all (allow absence)
|
||||
assert 'CODE_SIGN_IDENTITY[sdk=' not in src or '"-"' not in src, "ad-hoc identity marker still present"
|
||||
Reference in New Issue
Block a user