diff --git a/README.md b/README.md index be09e34..7bebc35 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ **A sovereign, local-first compute fabric for trusted devices.** -[![Tests](https://img.shields.io/badge/tests-160%20passing-00FF88?style=flat-square&labelColor=06090F)](./tests/test_sovereign_mesh.py) -[![Release](https://img.shields.io/badge/release-v0.1.3-F6C177?style=flat-square&labelColor=06090F)](./README.md#current-status) +[![Tests](https://img.shields.io/badge/tests-185%20passing-00FF88?style=flat-square&labelColor=06090F)](./tests/test_sovereign_mesh.py) +[![Release](https://img.shields.io/badge/release-v0.1.4-F6C177?style=flat-square&labelColor=06090F)](./README.md#current-status) [![Version](https://img.shields.io/badge/wire%20version-sovereign--mesh%2Fv1-00D4FF?style=flat-square&labelColor=06090F)](./docs/OCP_STATUS.md) [![Status](https://img.shields.io/badge/status-active%20development-C8A96E?style=flat-square&labelColor=06090F)](./docs/OCP_MASTER_PLAN.md) [![Protocol](https://img.shields.io/badge/protocol-OCP%20v0.1-7BC6FF?style=flat-square&labelColor=06090F)](./docs/OCP_STATUS.md) @@ -131,7 +131,7 @@ Some devices are powerful. Some are private. Some are fragile. Some are approval | `server_control_page.py` | Extracted control-deck renderer for the advanced operator surface | | `server_http_handlers.py` | Grouped HTTP route handlers so `server.py` stays a thin transport host | | `docs/` | Protocol notes, status, and roadmap | -| `tests/test_sovereign_mesh.py` | Regression suite — 160 tests | +| `tests/test_sovereign_mesh.py` | Regression suite — 185 tests | **Key runtime concepts:** @@ -205,8 +205,28 @@ For a fuller walkthrough, see [docs/QUICKSTART.md](./docs/QUICKSTART.md). OCP ships a built-in one-app surface at `GET /` and `GET /app`. It is designed for phone browsers and can be added to the home screen as a local-first operator app. +Mac beta desktop launcher: + +```bash +python3 -m ocp_desktop.launcher +``` + +The launcher keeps state under `~/Library/Application Support/OCP/`, can start a local-only node or LAN-reachable Mesh Mode node, opens the OCP app, and shows the phone link for testing on the same Wi-Fi. + +Unsigned macOS beta bundle: + +```bash +python3 scripts/build_macos_app.py +open dist/OCP.app +``` + +The beta `.app` requires `python3` to be installed on the Mac. It excludes local state, identities, databases, `.git`, caches, and test artifacts from the bundle. + +When Mesh Mode is started from the desktop launcher, copied phone links include an operator token in the URL fragment and the browser stores it locally for OCP POST actions. If you start the server manually with `OCP_HOST=0.0.0.0`, set `OCP_OPERATOR_TOKEN` and open `http://HOST_IP:8421/app#ocp_operator_token=YOUR_TOKEN` from the phone. + Inside the app: +- `Today` shows mesh strength, Autonomic Mesh status, latest proof, next actions, and a phone link/QR - `Setup` embeds the easy setup flow from `GET /easy` - `Control` embeds the advanced control deck from `GET /control` - `Protocol` links the live manifest, device profile, and HTTP contract from `/mesh/*` @@ -262,33 +282,21 @@ python3 -m unittest tests.test_sovereign_mesh python3 server.py --help ``` -Current baseline: **158 tests passing.** +Current baseline: **185 tests passing.** --- ## Current Status -**Released in v0.1.3** - -- protocol-kernel refactor that extracts real subsystem seams for protocol, state, scheduler, execution, artifacts, missions, helpers, and governance -- `SovereignMesh` retained as the stable façade so routes, persistence, and current behavior stay compatible -- execution boundary now owns runtime adapters, job submission and acceptance orchestration, and result packaging -- broad regression suite remains green at 160 passing tests -- continuity alpha now includes a 7026 vision document plus mission continuity vessel planning, verification, dry-run restore planning, `vessel`/`witness` artifact export, continuity metadata in manifests and mission state, continuity-aware scheduler explanations, additive treaty-aware continuity validation, and treaty posture surfaced in manifests and continuity summaries - -**Current main after v0.1.3** +**Released in v0.1.4** -- peer and discovery projections now expose treaty compatibility and custody-readiness hints -- connect, sync, and handshake flows return plain-language `operator_summary` and `recommended_action` fields -- peer lifecycle events and control-stream payloads carry compact treaty advisory state for live operator surfaces -- mission continuity now recommends treaty/custody-capable restore targets when available -- unified app shell at `/` and `/app` brings setup, control, and protocol inspection into one phone-friendly surface -- easy setup and the advanced control deck still surface treaty posture without requiring raw JSON inspection -- server internals now have grouped route modules, a dedicated `server_control_page.py` renderer, and a `/mesh/contract` snapshot with reusable protocol schemas, conformance fixtures, and lightweight ingress validation for the live HTTP surface -- `server.py` now delegates grouped route families through `server_http_handlers.py`, keeping the HTTP host thin while preserving the existing route surface -- treaty-bound artifact replication now enforces active local treaties plus custody-capable remote peers for sealed continuity artifacts -- the repo now includes `scripts/check_protocol_conformance.py`, and CI runs it before the broader regression suite so protocol drift and package-boundary regressions fail earlier -- artifact and mission services now rely more directly on their own row mappers instead of routing those conversions back through `SovereignMesh` +- Autonomic Mesh alpha adds route health, one-button activation, proof repair, helper-safe enlistment, and app-visible summaries. +- Desktop Alpha RC adds a Mac beta launcher, unsigned `.app` bundle builder, and a polished `/app` Today surface backed by `GET /mesh/app/status`. +- LAN operator hardening now requires signed peer traffic or operator-token authenticated raw mesh mutations from non-loopback clients. +- Private artifact content fetches now require operator auth unless the artifact policy is public. +- Runtime execution now defaults to explicit environment inheritance, with `inherit_env_allowlist` for deliberate host env pass-through. +- The signed envelope implementation now uses dependency-free Ed25519 helpers under `ed25519-sha512-v1`. +- The protocol-kernel refactor and mission-continuity/treaty foundation from v0.1.3 remain intact, with the full regression suite green at 185 tests. **Implemented in the current runtime** @@ -311,7 +319,7 @@ Current baseline: **158 tests passing.** ## Current Framing - `OCP v0.1` — protocol and spec draft -- `v0.1.3` — current implementation release +- `v0.1.4` — current implementation release - `Sovereign Mesh` — Python-first reference implementation - `sovereign-mesh/v1` — current wire version @@ -334,6 +342,7 @@ OCP is already past "protocol sketch" stage. If it keeps going in this direction ## Related - [Status](./docs/OCP_STATUS.md) +- [v0.1.4 Release Notes](./docs/RELEASE_v0.1.4.md) - [7026 Vision](./docs/OCP_7026_VISION.md) - [Quickstart](./docs/QUICKSTART.md) - [Master Plan](./docs/OCP_MASTER_PLAN.md) diff --git a/docs/OCP_MASTER_PLAN.md b/docs/OCP_MASTER_PLAN.md index 60f9884..e3b8221 100644 --- a/docs/OCP_MASTER_PLAN.md +++ b/docs/OCP_MASTER_PLAN.md @@ -99,7 +99,7 @@ Core abstractions: Near-term implementation: -- move from dependency-free Schnorr-style signing to Ed25519 +- continue hardening dependency-free Ed25519 signing with key rotation and signature agility - keep nonce replay tables and signed envelopes - support pinned peer keys and future multi-key rotation records diff --git a/docs/OCP_STATUS.md b/docs/OCP_STATUS.md index c13d2f2..29fc76b 100644 --- a/docs/OCP_STATUS.md +++ b/docs/OCP_STATUS.md @@ -12,6 +12,7 @@ Related planning docs: ## Current framing - `OCP v0.1` = protocol/spec draft +- `v0.1.4` = current Desktop Alpha RC implementation release - `Sovereign Mesh` = current Python-first reference implementation - `sovereign-mesh/v1` = current wire version @@ -71,6 +72,10 @@ Related planning docs: - Active peer seek/discovery with candidate tracking, optional auto-connect, and mesh-visible discovery records - First operator-grade `Connect Devices` flow in the control deck with nearby scan, one-click connect, built-in reachability diagnostics, and one-click test missions - New unified OCP app shell at `GET /` and `GET /app` so phone and desktop operators get setup, control, and protocol inspection in one surface +- App home now includes a `Today` panel with mesh strength, Autonomic Mesh activation, latest proof state, next actions, phone link/QR, and route-health summaries +- Compact app status API at `GET /mesh/app/status` so product surfaces can render operator state without scraping the advanced cockpit +- Mac-first beta desktop launcher with Local Only and Mesh Mode starts, Application Support state defaults, live status, app opening, and phone/LAN link copy +- Unsigned macOS beta bundle builder at `python3 scripts/build_macos_app.py` that excludes local state, identities, databases, git metadata, caches, and test artifacts - Plain-language easy setup remains available at `GET /easy` so first-run pairing can stay friendly while `/control` remains the advanced cockpit module - Easy setup share-link copy and plain troubleshooting guidance so nearby pairing can fall back to “copy this link to the other computer” instead of terminal instructions - QR pairing on the easy page plus an auto-open launcher script so first-run setup can start with `python3 scripts/start_ocp_easy.py` and a scannable pairing link @@ -171,7 +176,12 @@ Related planning docs: - HTTP contract and schema snapshot: `GET /mesh/contract` - Unified OCP app: `GET /` - Installable app shell: `GET /app` +- App status: `GET /mesh/app/status` - App manifest: `GET /app.webmanifest` +- Autonomic status: `GET /mesh/autonomy/status` +- Autonomic activation: `POST /mesh/autonomy/activate` +- Route health: `GET /mesh/routes/health` +- Route probe: `POST /mesh/routes/probe` - Easy setup module: `GET /easy` - Phone control module: `GET /control` - Phone control stream: `GET /mesh/control/stream` @@ -254,7 +264,7 @@ Related planning docs: ## Recommended next OCP builds -1. Turn the control-deck pair/connect flow into a packaged desktop app with firewall prompts, tray presence, and startup defaults so OCP feels native instead of server-first. +1. Add signed/notarized packaging, tray presence, startup defaults, and deeper firewall prompts after the unsigned Mac beta launcher proves the flow. 2. Add a mission launch helper in the control surface so operators can create single-job or cooperative missions without dropping to raw JSON. ## Broader roadmap @@ -270,4 +280,4 @@ python3 -m unittest tests.test_sovereign_mesh ``` Current standalone baseline: -- `tests.test_sovereign_mesh`: 156 tests passing +- `tests.test_sovereign_mesh`: 185 tests passing diff --git a/docs/OPEN_COMPUTE_PROTOCOL_v0.1.md b/docs/OPEN_COMPUTE_PROTOCOL_v0.1.md index f812ebc..3d6e275 100644 --- a/docs/OPEN_COMPUTE_PROTOCOL_v0.1.md +++ b/docs/OPEN_COMPUTE_PROTOCOL_v0.1.md @@ -103,7 +103,7 @@ All mutable OCP requests are carried in a signed envelope: "protocol_release": "0.1", "implementation": "Sovereign Mesh", "protocol_version": "sovereign-mesh/v1", - "signature_scheme": "schnorr-sha256-modp1024-v1", + "signature_scheme": "ed25519-sha512-v1", "signature": "..." }, "body": { @@ -150,6 +150,11 @@ The current reference implementation exposes the protocol under `/mesh/*`. | `/mesh/artifacts/publish` | POST | Publish artifact | | `/mesh/artifacts/{artifact_id}` | GET | Fetch artifact subject to policy | | `/mesh/agents/handoff` | POST | Send explicit delegation packet | +| `/mesh/app/status` | GET | Operator/app-facing status projection | +| `/mesh/autonomy/status` | GET | Current Autonomic Mesh posture | +| `/mesh/autonomy/activate` | POST | Assisted discovery, route probing, helper planning, and proof activation | +| `/mesh/routes/health` | GET | Known route-candidate health projection | +| `/mesh/routes/probe` | POST | Probe and refresh reachable peer routes | ## Execution Lifecycle @@ -197,14 +202,15 @@ It does not include: The working implementation in this repo intentionally stays pragmatic: -- signature scheme is currently dependency-free Schnorr-style signing, not Ed25519 +- signature scheme is currently dependency-free Ed25519 signing under `ed25519-sha512-v1` - stream sync is snapshot-and-cursor based, not yet a permanent duplex session manager - Golem is treated as a provider lane, not a trust authority - the standalone OCP store remains the local source of truth for mesh runtime state +- app/autonomy routes are operator-facing control surfaces, not consensus or settlement surfaces ## Planned OCP v0.2 Themes -- standardized Ed25519 signatures +- signature agility, key rotation, and scoped capability grants - true duplex federation sessions - background sync daemon - richer executor and scheduling metadata diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 6ea17b1..71986fd 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -25,6 +25,22 @@ That launcher: - keeps the advanced deck available at `/control` - prints detected LAN share URLs automatically when the node is reachable from other devices +Mac beta app launcher: + +```bash +python3 -m ocp_desktop.launcher +``` + +Use `Start Local Only` for a private node or `Start Mesh Mode` when you want your phone or spare laptop on the same Wi-Fi to connect. The launcher stores its beta app state under `~/Library/Application Support/OCP/`. +In Mesh Mode, `Copy Phone Link` includes a private operator token in the URL fragment so the phone app can safely run OCP actions such as `Activate Autonomic Mesh`. + +Build the unsigned Mac beta bundle: + +```bash +python3 scripts/build_macos_app.py +open dist/OCP.app +``` + If you prefer the shell starter instead: ```bash @@ -88,6 +104,22 @@ Bind so another machine on your network can reach it: OCP_HOST=0.0.0.0 ./scripts/start_ocp.sh ``` +If you want phone/LAN control actions when starting manually, set an operator token and open the app with that token in the URL fragment: + +```bash +OCP_OPERATOR_TOKEN=change-me OCP_HOST=0.0.0.0 python3 scripts/start_ocp_easy.py +``` + +```text +http://HOST_IP:8421/app#ocp_operator_token=change-me +``` + +On Windows PowerShell, set environment variables before starting Python: + +```powershell +$env:OCP_HOST="0.0.0.0"; $env:OCP_NODE_ID="beta-node"; $env:OCP_DISPLAY_NAME="Beta"; python scripts/start_ocp_easy.py +``` + If you are testing the UI from another machine and the deck is empty, seed activity against the LAN URL: ```bash @@ -115,6 +147,7 @@ OCP_HOST=0.0.0.0 OCP_PORT=8422 OCP_NODE_ID=beta-node OCP_DISPLAY_NAME=Beta pytho ``` Then open `http://HOST_IP:8421/` on each machine, choose the `Setup` tab, use `Connect Everything`, then `Test Whole Mesh`. +For the polished flow, open `/app` from the phone and press `Activate Autonomic Mesh`; it will scan, probe routes, plan safe helpers, run a whole-mesh proof, and explain what happened. If scan does not immediately find the other machine, use `Copy My Easy Link` on one computer and paste that address into the manual connect box on the other one. You can also scan the QR code from the easy page on the other device and open the pairing link that way. @@ -124,5 +157,7 @@ You can also scan the QR code from the easy page on the other device and open th - OCP is standalone. - Personal Mirror can integrate with it, but is not required to run it. - The main operator app is `/`. +- The app status API is `/mesh/app/status`. +- Autonomic Mesh APIs are `/mesh/autonomy/status`, `/mesh/autonomy/activate`, `/mesh/routes/health`, and `/mesh/routes/probe`. - The easy setup module remains at `/easy`. - The advanced deck module remains at `/control`. diff --git a/docs/RELEASE_v0.1.4.md b/docs/RELEASE_v0.1.4.md new file mode 100644 index 0000000..9973952 --- /dev/null +++ b/docs/RELEASE_v0.1.4.md @@ -0,0 +1,58 @@ +# OCP v0.1.4 Release Notes + +Date: 2026-04-23 + +`v0.1.4` is the Desktop Alpha RC release. It keeps the Python-first Sovereign Mesh runtime and SQLite substrate intact while making OCP feel more like one local-first personal compute app. + +## Highlights + +- Autonomic Mesh alpha: `Activate Autonomic Mesh` scans nearby devices, probes working routes, plans safe helpers, runs a whole-mesh proof, retries once after route repair, and returns plain-language summaries. +- Route health is first-class: OCP records route candidates, prefers recently proven reachable URLs, and exposes route state at `GET /mesh/routes/health` and `POST /mesh/routes/probe`. +- Polished app home: `/` and `/app` now show a Today panel with mesh strength, route health, latest proof state, next actions, phone link/QR, and direct activation. +- Mac beta launcher: `python3 -m ocp_desktop.launcher` starts Local Only or Mesh Mode nodes with state under `~/Library/Application Support/OCP/`. +- Unsigned Mac beta bundle: `python3 scripts/build_macos_app.py` creates `dist/OCP.app` and excludes local state, identities, DB files, `.git`, caches, and common secret files. +- Operator hardening: raw `/mesh/*` mutation routes require loopback access or `OCP_OPERATOR_TOKEN`; signed peer routes remain signed-envelope based. +- Safer execution defaults: host environment inheritance is off by default and can be enabled deliberately with `env_policy.inherit_env_allowlist`. +- Envelope crypto now uses the dependency-free Ed25519 implementation identified by `ed25519-sha512-v1`. + +## Public Surfaces + +- App shell: `GET /` and `GET /app` +- App status: `GET /mesh/app/status` +- Autonomic Mesh: `GET /mesh/autonomy/status` and `POST /mesh/autonomy/activate` +- Route health: `GET /mesh/routes/health` and `POST /mesh/routes/probe` +- Desktop launcher config: `~/Library/Application Support/OCP/launcher.json` +- Desktop launcher state: `~/Library/Application Support/OCP/state/` + +## Upgrade Notes + +- Existing local identities created with the older signature scheme may be regenerated on first start. Reconnect/re-pair trusted peers if an old node identity no longer matches. +- Phone/LAN POST actions need operator auth. The Mac launcher copies phone links with `#ocp_operator_token=...` so the browser can store the token locally and send `X-OCP-Operator-Token`. +- Manual LAN starts should set `OCP_OPERATOR_TOKEN` and open `http://HOST_IP:8421/app#ocp_operator_token=YOUR_TOKEN` from the phone. +- Private artifact content is no longer fetchable from off-loopback clients unless the request is operator-authenticated or the artifact policy is public. +- Jobs that relied on inherited host environment variables must declare `env_policy.inherit_host_env` and/or `env_policy.inherit_env_allowlist`. +- The Mac app is unsigned and not notarized in this release. It requires a local `python3` installation. + +## Verification + +Release candidates should pass: + +```bash +git diff --check +python3 scripts/check_protocol_conformance.py +python3 -m unittest tests.test_sovereign_mesh -q +python3 server.py --help +./scripts/start_ocp.sh --help +python3 scripts/start_ocp_easy.py --help +python3 scripts/build_macos_app.py --help +python3 -m ocp_desktop.launcher --plan local +``` + +Manual RC demo: + +1. Start Alpha in Mesh Mode. +2. Open the copied phone link on the same Wi-Fi. +3. Connect Beta or a spare laptop. +4. Press `Activate Autonomic Mesh`. +5. Confirm the proof completes and the app reports the mesh as strong. +6. Restart or kill Beta, activate again, and confirm OCP either repairs the route or gives one concrete fix. diff --git a/integrations/personal_mirror_server.py b/integrations/personal_mirror_server.py index d52e18d..271606f 100644 --- a/integrations/personal_mirror_server.py +++ b/integrations/personal_mirror_server.py @@ -803,6 +803,15 @@ def _structured_log(event: str, **fields): "/mesh/peers/sync", } +_SIGNED_MESH_POST_PATHS = { + "/mesh/handshake", + "/mesh/jobs/submit", + "/mesh/artifacts/publish", + "/mesh/agents/handoff", +} + +_PROTECTED_MESH_ARTIFACT_CONTENT_PATH = "/mesh/artifacts/content" + def _is_client_disconnect(exc: BaseException) -> bool: if isinstance(exc, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): @@ -844,8 +853,10 @@ def _route_requires_agent_auth(method: str, path: str) -> bool: route_method = (method or "").strip().upper() route_path = (path or "").strip() if route_method == "GET": - return route_path in _PROTECTED_GET_PATHS + return route_path in _PROTECTED_GET_PATHS or route_path == _PROTECTED_MESH_ARTIFACT_CONTENT_PATH if route_method == "POST": + if route_path.startswith("/mesh/") and route_path not in _SIGNED_MESH_POST_PATHS: + return True return route_path in _PROTECTED_POST_PATHS return False @@ -4678,9 +4689,23 @@ def _handle_mesh_artifact_get(self, path: str, params): self._send_json({"error": "artifact id is required"}, 400) return artifact_id = path[len(prefix):].strip("/") - requester_peer_id = params.get("peer_id", [""])[0] include_content = params.get("include_content", ["1"])[0].strip() != "0" - self._send_json(mesh.get_artifact(artifact_id, requester_peer_id=requester_peer_id, include_content=include_content)) + if include_content: + metadata = mesh.get_artifact(artifact_id, include_content=False) + is_public = mesh._policy_allows_peer(dict(metadata.get("policy") or {}), None) + client_host = self.client_address[0] if getattr(self, "client_address", None) else None + if not is_public and not _is_authorized_agent_request( + "GET", + _PROTECTED_MESH_ARTIFACT_CONTENT_PATH, + self.headers, + client_host, + ): + self._send_json( + _authorization_failure_payload("GET", path, client_host), + 401, + ) + return + self._send_json(mesh.get_artifact(artifact_id, requester_peer_id="", include_content=include_content)) except Exception as e: self._send_json({"error": str(e)}, 404 if "not found" in str(e).lower() else 400) diff --git a/mesh/crypto.py b/mesh/crypto.py index 5241ffd..07c6106 100644 --- a/mesh/crypto.py +++ b/mesh/crypto.py @@ -1,11 +1,4 @@ -""" -mesh.crypto — lightweight asymmetric signing helpers for Sovereign Mesh. - -The Python standard library does not ship Ed25519 primitives, so this module -uses a small Schnorr-style signature over a safe-prime multiplicative group. -It is sufficient for local-first federation tests and protocol integrity -without adding external dependencies. -""" +"""Ed25519 signing helpers for Sovereign Mesh envelopes.""" from __future__ import annotations @@ -13,85 +6,172 @@ import secrets -# 1024-bit Oakley group 2 safe prime from RFC 2409 / RFC 3526 family. -_P = int( - ( - "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E08" - "8A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD" - "3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E" - "7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899F" - "A5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF" - ), - 16, -) -_Q = (_P - 1) // 2 -_G = 4 +SIGNATURE_SCHEME = "ed25519-sha512-v1" + +_Q = 2**255 - 19 +_L = 2**252 + 27742317777372353535851937790883648493 +_D = (-121665 * pow(121666, _Q - 2, _Q)) % _Q +_I = pow(2, (_Q - 1) // 4, _Q) +_IDENTITY = (0, 1, 1, 0) + + +def _xrecover(y: int) -> int: + xx = (y * y - 1) * pow(_D * y * y + 1, _Q - 2, _Q) + x = pow(xx, (_Q + 3) // 8, _Q) + if (x * x - xx) % _Q != 0: + x = (x * _I) % _Q + if x & 1: + x = _Q - x + return x + + +def _extended_from_affine(point: tuple[int, int]) -> tuple[int, int, int, int]: + x, y = point + return x % _Q, y % _Q, 1, (x * y) % _Q + + +_B_Y = 4 * pow(5, _Q - 2, _Q) % _Q +_B = _extended_from_affine((_xrecover(_B_Y), _B_Y)) + + +def _edwards_add( + point: tuple[int, int, int, int], + other: tuple[int, int, int, int], +) -> tuple[int, int, int, int]: + x1, y1, z1, t1 = point + x2, y2, z2, t2 = other + a = ((y1 - x1) * (y2 - x2)) % _Q + b = ((y1 + x1) * (y2 + x2)) % _Q + c = (2 * _D * t1 * t2) % _Q + d = (2 * z1 * z2) % _Q + e = (b - a) % _Q + f = (d - c) % _Q + g = (d + c) % _Q + h = (b + a) % _Q + return (e * f) % _Q, (g * h) % _Q, (f * g) % _Q, (e * h) % _Q + + +def _scalarmult(point: tuple[int, int, int, int], scalar: int) -> tuple[int, int, int, int]: + result = _IDENTITY + addend = point + remaining = int(scalar) + while remaining > 0: + if remaining & 1: + result = _edwards_add(result, addend) + addend = _edwards_add(addend, addend) + remaining >>= 1 + return result + + +def _to_affine(point: tuple[int, int, int, int]) -> tuple[int, int]: + x, y, z, _ = point + z_inv = pow(z, _Q - 2, _Q) + return (x * z_inv) % _Q, (y * z_inv) % _Q + -SIGNATURE_SCHEME = "schnorr-sha256-modp1024-v1" +def _points_equal(point: tuple[int, int, int, int], other: tuple[int, int, int, int]) -> bool: + x1, y1, z1, _ = point + x2, y2, z2, _ = other + return (x1 * z2 - x2 * z1) % _Q == 0 and (y1 * z2 - y2 * z1) % _Q == 0 -def _hash_to_int(*parts: bytes) -> int: - digest = hashlib.sha256() - for part in parts: - digest.update(part) - return int.from_bytes(digest.digest(), "big") % _Q +def _encode_point(point: tuple[int, int, int, int]) -> bytes: + x, y = _to_affine(point) + bits = int(y).to_bytes(32, "little") + return bits[:31] + bytes([bits[31] | ((x & 1) << 7)]) -def _int_to_bytes(value: int) -> bytes: - width = max(1, (_P.bit_length() + 7) // 8) - return int(value).to_bytes(width, "big") +def _point_is_on_curve(point: tuple[int, int]) -> bool: + x, y = point + return (-x * x + y * y - 1 - _D * x * x * y * y) % _Q == 0 + + +def _decode_point(encoded: bytes) -> tuple[int, int, int, int]: + if len(encoded) != 32: + raise ValueError("Ed25519 points must be 32 bytes") + y = int.from_bytes(encoded, "little") & ((1 << 255) - 1) + x_sign = encoded[31] >> 7 + x = _xrecover(y) + if (x & 1) != x_sign: + x = _Q - x + affine = (x, y) + if not _point_is_on_curve(affine): + raise ValueError("Ed25519 point is not on curve") + point = _extended_from_affine(affine) + if _points_equal(_scalarmult(point, 8), _IDENTITY): + raise ValueError("Ed25519 small-order point is not allowed") + return point + + +def _private_seed(private_key_hex: str) -> bytes: + try: + seed = bytes.fromhex((private_key_hex or "").strip()) + except ValueError as exc: + raise ValueError("private key must be hex") from exc + if len(seed) != 32: + raise ValueError("Ed25519 private key seed must be 32 bytes") + return seed + + +def _public_key_bytes(public_key_hex: str) -> bytes: + try: + public_key = bytes.fromhex((public_key_hex or "").strip()) + except ValueError as exc: + raise ValueError("public key must be hex") from exc + if len(public_key) != 32: + raise ValueError("Ed25519 public key must be 32 bytes") + return public_key + + +def _clamped_scalar(seed: bytes) -> tuple[int, bytes]: + digest = hashlib.sha512(seed).digest() + scalar_bytes = bytearray(digest[:32]) + scalar_bytes[0] &= 248 + scalar_bytes[31] &= 63 + scalar_bytes[31] |= 64 + return int.from_bytes(scalar_bytes, "little"), digest[32:] def generate_keypair() -> tuple[str, str]: - private_int = secrets.randbelow(_Q - 2) + 1 - public_int = pow(_G, private_int, _P) - return format(private_int, "x"), format(public_int, "x") + private_key = secrets.token_bytes(32) + return private_key.hex(), public_key_from_private(private_key.hex()) def public_key_from_private(private_key_hex: str) -> str: - private_int = int((private_key_hex or "0").strip(), 16) - if private_int <= 0: - raise ValueError("private key is required") - return format(pow(_G, private_int, _P), "x") + seed = _private_seed(private_key_hex) + scalar, _ = _clamped_scalar(seed) + return _encode_point(_scalarmult(_B, scalar)).hex() def sign_message(private_key_hex: str, payload: bytes) -> str: - private_int = int((private_key_hex or "0").strip(), 16) - if private_int <= 0: - raise ValueError("private key is required") - public_int = pow(_G, private_int, _P) - nonce = secrets.randbelow(_Q - 2) + 1 - commitment = pow(_G, nonce, _P) - challenge = _hash_to_int( - _int_to_bytes(public_int), - _int_to_bytes(commitment), - payload, - ) - response = (nonce + private_int * challenge) % _Q - return f"{format(commitment, 'x')}.{format(response, 'x')}" + seed = _private_seed(private_key_hex) + scalar, prefix = _clamped_scalar(seed) + public_key = _encode_point(_scalarmult(_B, scalar)) + message = bytes(payload or b"") + r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L + encoded_r = _encode_point(_scalarmult(_B, r)) + challenge = int.from_bytes(hashlib.sha512(encoded_r + public_key + message).digest(), "little") % _L + s = (r + challenge * scalar) % _L + return (encoded_r + s.to_bytes(32, "little")).hex() def verify_message(public_key_hex: str, payload: bytes, signature: str) -> bool: try: - public_int = int((public_key_hex or "0").strip(), 16) - commitment_hex, response_hex = (signature or "").split(".", 1) - commitment = int(commitment_hex, 16) - response = int(response_hex, 16) + public_key = _public_key_bytes(public_key_hex) + signature_bytes = bytes.fromhex((signature or "").strip()) + if len(signature_bytes) != 64: + return False + encoded_r = signature_bytes[:32] + s = int.from_bytes(signature_bytes[32:], "little") + if s >= _L: + return False + public_point = _decode_point(public_key) + r_point = _decode_point(encoded_r) except Exception: return False - if public_int <= 1 or public_int >= _P: - return False - if commitment <= 0 or commitment >= _P: - return False - if response <= 0 or response >= _Q: - return False - - challenge = _hash_to_int( - _int_to_bytes(public_int), - _int_to_bytes(commitment), - payload, - ) - left = pow(_G, response, _P) - right = (commitment * pow(public_int, challenge, _P)) % _P - return left == right + message = bytes(payload or b"") + challenge = int.from_bytes(hashlib.sha512(encoded_r + public_key + message).digest(), "little") % _L + left = _scalarmult(_B, s) + right = _edwards_add(r_point, _scalarmult(public_point, challenge)) + return _points_equal(left, right) diff --git a/mesh/sovereign.py b/mesh/sovereign.py index 4f39332..cf14c1b 100644 --- a/mesh/sovereign.py +++ b/mesh/sovereign.py @@ -9,6 +9,7 @@ import dataclasses import datetime as dt import hashlib +import hmac import ipaddress import json import logging @@ -27,6 +28,7 @@ from urllib.request import Request, urlopen from mesh_artifacts import MeshArtifactService +from mesh_autonomy import MeshAutonomyService from mesh_execution import MeshExecutionService from mesh_governance import MeshGovernanceService from mesh_helpers import MeshHelperService @@ -53,7 +55,7 @@ from mesh_scheduler import MeshSchedulerService from mesh_state import MeshStateService, initialize_mesh_schema -from .crypto import SIGNATURE_SCHEME, generate_keypair, sign_message, verify_message +from .crypto import SIGNATURE_SCHEME, generate_keypair, public_key_from_private, sign_message, verify_message logger = logging.getLogger(__name__) @@ -1051,6 +1053,13 @@ def __init__(self, base_url: str, *, timeout: float = 8.0): self.base_url = (base_url or "").rstrip("/") self.timeout = float(timeout) + def _operator_token(self) -> str: + return ( + os.environ.get("OCP_OPERATOR_TOKEN") + or os.environ.get("OCP_CONTROL_TOKEN") + or "" + ).strip() + def _request_json(self, method: str, path: str, payload: Optional[dict] = None, params: Optional[dict] = None) -> dict: url = self.base_url + path if params: @@ -1059,6 +1068,9 @@ def _request_json(self, method: str, path: str, payload: Optional[dict] = None, url += ("&" if "?" in url else "?") + query data = None headers = {"Accept": "application/json"} + operator_token = self._operator_token() + if operator_token: + headers["X-OCP-Operator-Token"] = operator_token if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" @@ -1096,6 +1108,18 @@ def scan_local_peers(self, payload: Optional[dict] = None) -> dict: def connectivity_diagnostics(self) -> dict: return self._request_json("GET", "/mesh/connectivity/diagnostics") + def routes_health(self) -> dict: + return self._request_json("GET", "/mesh/routes/health") + + def probe_routes(self, payload: Optional[dict] = None) -> dict: + return self._request_json("POST", "/mesh/routes/probe", payload=payload or {}) + + def autonomic_status(self) -> dict: + return self._request_json("GET", "/mesh/autonomy/status") + + def activate_autonomic_mesh(self, payload: Optional[dict] = None) -> dict: + return self._request_json("POST", "/mesh/autonomy/activate", payload=payload or {}) + def connect_peer(self, payload: dict) -> dict: return self._request_json("POST", "/mesh/peers/connect", payload=payload) @@ -1773,6 +1797,14 @@ def __init__( sha256_bytes=_sha256_bytes, utcnow=_utcnow, ) + self.autonomy = MeshAutonomyService( + self, + peer_client_type=MeshPeerClient, + loads_json=_loads_json, + normalize_base_url=_normalize_base_url, + normalize_trust_tier=_normalize_trust_tier, + utcnow=_utcnow, + ) def _resolve_docker_enabled(self, explicit: Optional[bool]) -> bool: if explicit is not None: @@ -1811,19 +1843,39 @@ def _load_or_create_identity( explicit_display_name: Optional[str] = None, ) -> tuple[str, str, str, str]: path = self.mesh_root / "identity.json" + stored_identity: dict[str, Any] = {} + try: + self.mesh_root.chmod(0o700) + except OSError: + pass if path.exists(): - data = _loads_json(path.read_text(encoding="utf-8"), {}) - private_key = (data.get("private_key") or "").strip() - public_key = (data.get("public_key") or "").strip() - node_id = (explicit_node_id or data.get("node_id") or "").strip() - display_name = (explicit_display_name or data.get("display_name") or "").strip() - if private_key and public_key and node_id: + stored_identity = _loads_json(path.read_text(encoding="utf-8"), {}) + private_key = (stored_identity.get("private_key") or "").strip() + public_key = (stored_identity.get("public_key") or "").strip() + node_id = (explicit_node_id or stored_identity.get("node_id") or "").strip() + display_name = (explicit_display_name or stored_identity.get("display_name") or "").strip() + signature_scheme = (stored_identity.get("signature_scheme") or "").strip() + try: + derived_public_key = public_key_from_private(private_key) if private_key else "" + except Exception: + derived_public_key = "" + if ( + private_key + and public_key + and node_id + and signature_scheme == SIGNATURE_SCHEME + and hmac.compare_digest(derived_public_key, public_key) + ): + try: + path.chmod(0o600) + except OSError: + pass return node_id, display_name or node_id, private_key, public_key private_key, public_key = generate_keypair() hostname = socket.gethostname().split(".")[0] or "organism" - node_id = (explicit_node_id or f"{hostname}-{uuid.uuid4().hex[:8]}").strip() - display_name = (explicit_display_name or f"{hostname} organism").strip() + node_id = (explicit_node_id or stored_identity.get("node_id") or f"{hostname}-{uuid.uuid4().hex[:8]}").strip() + display_name = (explicit_display_name or stored_identity.get("display_name") or f"{hostname} organism").strip() payload = { "node_id": node_id, "display_name": display_name, @@ -1833,6 +1885,10 @@ def _load_or_create_identity( "created_at": _utcnow(), } path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + try: + path.chmod(0o600) + except OSError: + pass return node_id, display_name, private_key, public_key def _load_or_create_device_profile(self, *, explicit_device_profile: Optional[dict] = None) -> dict: @@ -2649,6 +2705,52 @@ def connectivity_diagnostics(self, *, port: int = 0, limit: int = 24) -> dict: "recent_errors": recent_errors, } + def routes_health(self, *, limit: int = 50) -> dict: + return self.autonomy.routes_health(limit=limit) + + def probe_routes( + self, + *, + peer_id: str = "", + base_url: str = "", + timeout: float = 2.0, + limit: int = 8, + ) -> dict: + return self.autonomy.probe_routes( + peer_id=peer_id, + base_url=base_url, + timeout=timeout, + limit=limit, + ) + + def autonomy_status(self) -> dict: + return self.autonomy.status() + + def activate_autonomic_mesh( + self, + *, + mode: str = "assisted", + limit: int = 24, + scan_timeout: float = 0.8, + timeout: float = 3.0, + run_proof: bool = True, + repair: bool = True, + max_enlist: int = 2, + actor_agent_id: str = "ocp-autonomy", + request_id: Optional[str] = None, + ) -> dict: + return self.autonomy.activate( + mode=mode, + limit=limit, + scan_timeout=scan_timeout, + timeout=timeout, + run_proof=run_proof, + repair=repair, + max_enlist=max_enlist, + actor_agent_id=actor_agent_id, + request_id=request_id, + ) + def scan_local_peers( self, *, @@ -4558,9 +4660,17 @@ def _normalize_runtime_environment( or metadata_data.get("env_policy") or {} ) + raw_inherit_allowlist = env_policy_raw.get("inherit_env_allowlist") or [] + if isinstance(raw_inherit_allowlist, str): + raw_inherit_allowlist = [item.strip() for item in raw_inherit_allowlist.split(",")] env_policy = { - "inherit_host_env": _coerce_bool(env_policy_raw.get("inherit_host_env") if "inherit_host_env" in env_policy_raw else True), + "inherit_host_env": _coerce_bool(env_policy_raw.get("inherit_host_env") if "inherit_host_env" in env_policy_raw else False), "allow_env_override": _coerce_bool(env_policy_raw.get("allow_env_override") if "allow_env_override" in env_policy_raw else True), + "inherit_env_allowlist": [ + _normalize_env_var_name(item) + for item in raw_inherit_allowlist + if str(item or "").strip() + ], } filesystem_raw = dict( runtime_data.get("filesystem") @@ -4850,7 +4960,7 @@ def _validate_normalized_job_spec(self, spec: dict) -> None: if runtime_type not in {"shell", "python", "container", "wasm", "custom"}: raise MeshPolicyError("unsupported runtime_type") env_policy = dict(runtime_environment.get("env_policy") or {}) - if set(env_policy.keys()) - {"inherit_host_env", "allow_env_override"}: + if set(env_policy.keys()) - {"inherit_host_env", "allow_env_override", "inherit_env_allowlist"}: raise MeshPolicyError("unsupported env_policy field") filesystem = dict(runtime_environment.get("filesystem") or {}) if str(filesystem.get("profile") or "workspace") not in {"workspace", "isolated", "custom"}: diff --git a/mesh_autonomy/__init__.py b/mesh_autonomy/__init__.py new file mode 100644 index 0000000..063d08d --- /dev/null +++ b/mesh_autonomy/__init__.py @@ -0,0 +1,3 @@ +from .service import MeshAutonomyService + +__all__ = ["MeshAutonomyService"] diff --git a/mesh_autonomy/service.py b/mesh_autonomy/service.py new file mode 100644 index 0000000..1f7e239 --- /dev/null +++ b/mesh_autonomy/service.py @@ -0,0 +1,1005 @@ +from __future__ import annotations + +import json +import time +import uuid +import datetime as dt +from typing import Any, Callable, Optional + +ROUTE_FRESH_SECONDS = 300 +ROUTE_STALE_SECONDS = 1800 + + +class MeshAutonomyService: + """Mesh-level autonomy coordinator. + + This service owns route health and the one-button activation flow. It + deliberately delegates scheduling, helper enlistment, approvals, missions, + and notifications back to the existing mesh services so the alpha remains + additive rather than becoming a parallel runtime. + """ + + def __init__( + self, + mesh, + *, + peer_client_type, + loads_json: Callable[[Any, Any], Any], + normalize_base_url: Callable[..., str], + normalize_trust_tier: Callable[[Optional[str]], str], + utcnow: Callable[[], str], + ): + self.mesh = mesh + self._peer_client_type = peer_client_type + self._loads_json = loads_json + self._normalize_base_url = normalize_base_url + self._normalize_trust_tier = normalize_trust_tier + self._utcnow = utcnow + + def route_candidates_for_peer(self, peer: dict, *, base_url: str = "") -> list[dict[str, Any]]: + peer = dict(peer or {}) + peer_id = str(peer.get("peer_id") or "").strip() + metadata = dict(peer.get("metadata") or {}) + candidates: list[dict[str, Any]] = [] + seen: set[str] = set() + + def append(raw_url: str, *, source: str, extra: Optional[dict] = None) -> None: + normalized = self._normalize_base_url(str(raw_url or "").strip()) + if not normalized or normalized in seen: + return + seen.add(normalized) + payload = { + "base_url": normalized, + "source": source, + "status": "unknown", + "latency_ms": None, + "checked_at": "", + "last_success_at": "", + "last_error": "", + } + payload.update(dict(extra or {})) + payload["base_url"] = normalized + payload["source"] = str(payload.get("source") or source).strip() or source + candidates.append(payload) + + append(base_url, source="explicit") + append(metadata.get("last_reachable_base_url") or "", source="last_reachable") + for item in list(metadata.get("route_candidates") or []): + if not isinstance(item, dict): + continue + append(item.get("base_url") or "", source=str(item.get("source") or "history").strip(), extra=item) + if peer_id: + discovery = self.mesh._discovery_candidate_by_peer_id(peer_id) + if discovery: + append(discovery.get("base_url") or discovery.get("endpoint_url") or "", source="discovery") + append(peer.get("endpoint_url") or "", source="advertised") + return candidates + + def _peer_from_manifest(self, manifest: dict) -> tuple[str, dict]: + card = dict((manifest or {}).get("organism_card") or {}) + peer_id = str(card.get("organism_id") or card.get("node_id") or "").strip() + return peer_id, card + + def _event(self, event_type: str, *, peer_id: str = "", request_id: str = "", payload: Optional[dict] = None) -> None: + try: + self.mesh._record_event(event_type, peer_id=peer_id, request_id=request_id, payload=dict(payload or {})) + except Exception: + self.mesh.logger.debug("mesh autonomy event recording failed", exc_info=True) + + def _action( + self, + actions: list[dict[str, Any]], + kind: str, + status: str, + summary: str, + *, + peer_id: str = "", + details: Optional[dict] = None, + request_id: str = "", + ) -> dict[str, Any]: + action = { + "id": str(uuid.uuid4()), + "kind": str(kind or "autonomy.action").strip(), + "status": str(status or "ok").strip(), + "summary": str(summary or "").strip(), + "peer_id": str(peer_id or "").strip(), + "details": dict(details or {}), + "created_at": self._utcnow(), + } + actions.append(action) + self._event("mesh.autonomy.action", peer_id=peer_id or self.mesh.node_id, request_id=request_id, payload=action) + return action + + def _route_summary(self, route: dict) -> str: + peer_label = route.get("display_name") or route.get("peer_id") or "Peer" + status = str(route.get("status") or "unknown").strip().lower() + best = route.get("best_route") or route.get("last_reachable_base_url") or "" + freshness = str(route.get("freshness") or "").strip().lower() + if status == "reachable": + if freshness == "stale": + return f"{peer_label} was reachable at {best}, but the proof is stale. Probe it before dispatch." + if freshness == "aging": + return f"{peer_label} is reachable at {best}; route proof is aging." + return f"{peer_label} is reachable at {best}." + if status == "unreachable": + hint = route.get("operator_hint") or route.get("last_error") or "last route probe failed" + return f"{peer_label} needs attention: {hint}" + return f"{peer_label} has no proven route yet." + + def _parse_time(self, value: Any) -> Optional[dt.datetime]: + token = str(value or "").strip() + if not token: + return None + try: + parsed = dt.datetime.fromisoformat(token.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + + def _now_dt(self) -> dt.datetime: + parsed = self._parse_time(self._utcnow()) + return parsed or dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + + def _format_time(self, value: dt.datetime) -> str: + return value.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + def _route_age_seconds(self, *timestamps: Any) -> Optional[int]: + parsed = [value for value in (self._parse_time(item) for item in timestamps) if value is not None] + if not parsed: + return None + latest = max(parsed) + return max(0, int((self._now_dt() - latest).total_seconds())) + + def _route_freshness(self, *, status: str, checked_at: Any = "", last_success_at: Any = "") -> str: + if str(status or "").strip().lower() != "reachable": + return "failed" if str(status or "").strip().lower() == "unreachable" else "unknown" + age = self._route_age_seconds(last_success_at, checked_at) + if age is None: + return "unknown" + if age <= ROUTE_FRESH_SECONDS: + return "fresh" + if age <= ROUTE_STALE_SECONDS: + return "aging" + return "stale" + + def _route_repair_hint(self, error: str, base_url: str) -> str: + text = str(error or "").strip() + lowered = text.lower() + target = str(base_url or "").strip() or "the peer URL" + if not text: + return "Probe the route again or reconnect the peer." + if "timed out" in lowered or "timeout" in lowered: + return f"{target} did not answer in time. Make sure OCP is running there and allow Python/OCP through the firewall on that device." + if "connection refused" in lowered or "actively refused" in lowered: + return f"{target} is reachable but OCP is not listening on that port. Start OCP on the peer or check the port." + if "no route to host" in lowered or "network is unreachable" in lowered or "name or service not known" in lowered: + return f"{target} is not reachable from this device. Put both devices on the same Wi-Fi or use the peer's current LAN address." + if "route reached" in lowered and "expected" in lowered: + return "This URL answered as a different OCP node. Re-scan nearby devices and use the route tied to the expected peer." + if "connection reset" in lowered: + return f"{target} accepted the connection then closed it. Restart OCP on the peer and try again." + return f"Route probe failed for {target}: {text}" + + def _next_probe_after(self, *, failure_count: int, checked_at: Any) -> str: + base = self._parse_time(checked_at) or self._now_dt() + delay_seconds = min(300, 15 * (2 ** max(0, min(5, int(failure_count or 1) - 1)))) + return self._format_time(base + dt.timedelta(seconds=delay_seconds)) + + def _route_is_usable(self, peer_or_route: dict) -> bool: + metadata = dict(peer_or_route.get("metadata") or {}) + route_health = dict(metadata.get("route_health") or {}) + status = str(route_health.get("status") or peer_or_route.get("status") or "").strip().lower() + freshness = str(peer_or_route.get("freshness") or route_health.get("freshness") or "").strip().lower() + has_route = bool( + peer_or_route.get("best_route") + or peer_or_route.get("last_reachable_base_url") + or metadata.get("last_reachable_base_url") + or route_health.get("best_route") + ) + if status in {"unreachable", "failed"} or freshness in {"stale", "failed"}: + return False + return status == "reachable" or (has_route and freshness != "stale") + + def _merge_route_candidate(self, peer_id: str, candidate: dict) -> dict: + peer_token = str(peer_id or "").strip() + if not peer_token: + return {} + peer = self.mesh._row_to_peer(self.mesh._get_peer_row(peer_token)) + if not peer: + return {} + metadata = dict(peer.get("metadata") or {}) + candidate = dict(candidate or {}) + base_url = self._normalize_base_url(candidate.get("base_url") or "") + if not base_url: + return peer + candidate["base_url"] = base_url + previous = next( + ( + dict(item) + for item in list(metadata.get("route_candidates") or []) + if isinstance(item, dict) and self._normalize_base_url(item.get("base_url") or "") == base_url + ), + {}, + ) + is_reachable = candidate.get("status") == "reachable" + failure_count = 0 if is_reachable else int(previous.get("failure_count") or 0) + 1 + candidate["failure_count"] = failure_count + candidate["operator_hint"] = "" if is_reachable else self._route_repair_hint(candidate.get("last_error") or "", base_url) + candidate["next_probe_after"] = "" if is_reachable else self._next_probe_after( + failure_count=failure_count, + checked_at=candidate.get("checked_at") or self._utcnow(), + ) + candidate["freshness"] = self._route_freshness( + status=str(candidate.get("status") or ""), + checked_at=candidate.get("checked_at") or "", + last_success_at=candidate.get("last_success_at") or "", + ) + existing = [ + dict(item) + for item in list(metadata.get("route_candidates") or []) + if isinstance(item, dict) and self._normalize_base_url(item.get("base_url") or "") != base_url + ] + metadata["route_candidates"] = [candidate, *existing][:8] + metadata["last_route_probe_at"] = candidate.get("checked_at") or self._utcnow() + route_health = dict(metadata.get("route_health") or {}) + route_health.update( + { + "status": candidate.get("status") or "unknown", + "best_route": base_url if candidate.get("status") == "reachable" else route_health.get("best_route", ""), + "checked_at": candidate.get("checked_at") or self._utcnow(), + "last_error": candidate.get("last_error") or "", + "latency_ms": candidate.get("latency_ms"), + "source": candidate.get("source") or "", + "freshness": candidate.get("freshness") or "unknown", + "failure_count": failure_count, + "next_probe_after": candidate.get("next_probe_after") or "", + "operator_hint": candidate.get("operator_hint") or "", + } + ) + if candidate.get("status") == "reachable": + route_health["last_success_at"] = candidate.get("last_success_at") or candidate.get("checked_at") or self._utcnow() + metadata["last_reachable_base_url"] = base_url + metadata["route_health"] = route_health + status = "connected" if candidate.get("status") == "reachable" else None + return self.mesh._update_peer_record(peer_token, metadata=metadata, status=status) + + def probe_routes( + self, + *, + peer_id: str = "", + base_url: str = "", + timeout: float = 2.0, + limit: int = 8, + ) -> dict[str, Any]: + peer_token = str(peer_id or "").strip() + explicit_url = self._normalize_base_url(base_url or "") + if not peer_token and not explicit_url: + results = [ + self.probe_routes(peer_id=str(peer.get("peer_id") or ""), timeout=timeout, limit=4) + for peer in list(self.mesh.list_peers(limit=max(1, int(limit or 8))).get("peers") or []) + if str(peer.get("peer_id") or "").strip() + ] + return { + "status": "ok", + "peer_id": "", + "count": len(results), + "reachable": sum(1 for item in results if int(item.get("reachable") or 0) > 0), + "results": results, + "generated_at": self._utcnow(), + } + + peer = self.mesh._row_to_peer(self.mesh._get_peer_row(peer_token)) if peer_token else {} + if not peer and peer_token: + return { + "status": "not_found", + "peer_id": peer_token, + "checked": 0, + "reachable": 0, + "best_route": "", + "candidates": [], + "operator_summary": f"{peer_token} is not known locally yet.", + "generated_at": self._utcnow(), + } + + candidates = self.route_candidates_for_peer(peer or {}, base_url=explicit_url) + if not candidates and explicit_url: + candidates = [{"base_url": explicit_url, "source": "explicit", "status": "unknown"}] + + checked: list[dict[str, Any]] = [] + best_route = "" + observed_peer_id = peer_token + for candidate in candidates[: max(1, int(limit or 8))]: + base = self._normalize_base_url(candidate.get("base_url") or "") + if not base: + continue + checked_at = self._utcnow() + started = time.monotonic() + try: + manifest = self._peer_client_type(base, timeout=float(timeout or 2.0)).manifest() + latency_ms = int((time.monotonic() - started) * 1000) + found_peer_id, card = self._peer_from_manifest(manifest) + if peer_token and found_peer_id and found_peer_id != peer_token: + raise ValueError(f"route reached {found_peer_id}, expected {peer_token}") + observed_peer_id = observed_peer_id or found_peer_id + record = { + **candidate, + "base_url": base, + "status": "reachable", + "latency_ms": latency_ms, + "checked_at": checked_at, + "last_success_at": checked_at, + "last_error": "", + "observed_peer_id": found_peer_id, + "failure_count": 0, + "next_probe_after": "", + "operator_hint": "", + "freshness": "fresh", + } + checked.append(record) + if observed_peer_id: + self._merge_route_candidate(observed_peer_id, record) + if found_peer_id and not self.mesh._get_peer_row(found_peer_id): + self.mesh._remember_discovery_candidate( + base_url=base, + peer_id=found_peer_id, + display_name=card.get("display_name") or found_peer_id, + endpoint_url=card.get("endpoint_url") or base, + status="discovered", + trust_tier=card.get("trust_tier") or "trusted", + device_profile=dict(card.get("device_profile") or manifest.get("device_profile") or {}), + manifest=manifest, + metadata={"route_probe": "reachable"}, + ) + best_route = best_route or base + except Exception as exc: + record = { + **candidate, + "base_url": base, + "status": "unreachable", + "latency_ms": None, + "checked_at": checked_at, + "last_success_at": candidate.get("last_success_at") or "", + "last_error": str(exc), + } + previous_failures = int(candidate.get("failure_count") or 0) + record["failure_count"] = previous_failures + 1 + record["next_probe_after"] = self._next_probe_after( + failure_count=record["failure_count"], + checked_at=checked_at, + ) + record["operator_hint"] = self._route_repair_hint(str(exc), base) + record["freshness"] = "failed" + checked.append(record) + if peer_token: + self._merge_route_candidate(peer_token, record) + + reachable = [item for item in checked if item.get("status") == "reachable"] + status = "ok" if reachable else "attention_needed" + result = { + "status": status, + "peer_id": observed_peer_id or peer_token, + "checked": len(checked), + "reachable": len(reachable), + "best_route": best_route, + "candidates": checked, + "operator_hint": "" if reachable else self._route_repair_hint( + checked[-1].get("last_error") if checked else "", + peer_token or explicit_url, + ), + "operator_summary": ( + f"{observed_peer_id or peer_token or 'Route'} is reachable at {best_route}." + if reachable + else f"No working route found for {peer_token or explicit_url}. " + f"{self._route_repair_hint(checked[-1].get('last_error') if checked else '', peer_token or explicit_url)}" + ), + "generated_at": self._utcnow(), + } + self._event( + "mesh.route.probed", + peer_id=observed_peer_id or peer_token or self.mesh.node_id, + payload={ + "peer_id": observed_peer_id or peer_token, + "best_route": best_route, + "checked": len(checked), + "reachable": len(reachable), + "candidates": checked, + }, + ) + return result + + def routes_health(self, *, limit: int = 50) -> dict[str, Any]: + routes = [] + for peer in list(self.mesh.list_peers(limit=max(1, int(limit or 50))).get("peers") or []): + metadata = dict(peer.get("metadata") or {}) + route_health = dict(metadata.get("route_health") or {}) + candidates = [dict(item) for item in list(metadata.get("route_candidates") or []) if isinstance(item, dict)] + last_reachable = self._normalize_base_url(metadata.get("last_reachable_base_url") or "") + best_route = self._normalize_base_url(route_health.get("best_route") or last_reachable or "") + status = str(route_health.get("status") or ("reachable" if best_route else "unknown")).strip().lower() + checked_at = route_health.get("checked_at") or metadata.get("last_route_probe_at") or "" + last_success_at = route_health.get("last_success_at") or "" + age_seconds = self._route_age_seconds(last_success_at, checked_at) + freshness = self._route_freshness(status=status, checked_at=checked_at, last_success_at=last_success_at) + route = { + "peer_id": peer.get("peer_id") or "", + "display_name": peer.get("display_name") or peer.get("peer_id") or "", + "status": status, + "freshness": freshness, + "age_seconds": age_seconds, + "best_route": best_route, + "last_reachable_base_url": last_reachable, + "checked_at": checked_at, + "last_success_at": last_success_at, + "last_error": route_health.get("last_error") or "", + "failure_count": int(route_health.get("failure_count") or 0), + "next_probe_after": route_health.get("next_probe_after") or "", + "operator_hint": route_health.get("operator_hint") or "", + "candidates": candidates, + } + route["operator_summary"] = self._route_summary(route) + routes.append(route) + healthy = sum(1 for route in routes if self._route_is_usable(route)) + return { + "status": "ok", + "peer_id": self.mesh.node_id, + "count": len(routes), + "healthy": healthy, + "routes": routes, + "operator_summary": ( + "No remote routes are known yet." + if not routes + else f"{healthy} of {len(routes)} peer route(s) are proven reachable." + ), + "generated_at": self._utcnow(), + } + + def _row_to_run(self, row) -> dict[str, Any]: + if row is None: + return {} + return { + "id": str(row["id"] or "").strip(), + "request_id": str(row["request_id"] or "").strip(), + "mode": str(row["mode"] or "assisted").strip(), + "status": str(row["status"] or "planned").strip(), + "summary": str(row["summary"] or "").strip(), + "actions": self._loads_json(row["actions"], []), + "result": self._loads_json(row["result"], {}), + "metadata": self._loads_json(row["metadata"], {}), + "created_at": row["created_at"] or "", + "updated_at": row["updated_at"] or "", + } + + def latest_run(self) -> dict[str, Any]: + with self.mesh._conn() as conn: + row = conn.execute( + "SELECT * FROM mesh_autonomy_runs ORDER BY created_at DESC, updated_at DESC LIMIT 1" + ).fetchone() + return self._row_to_run(row) + + def run_by_request_id(self, request_id: str) -> dict[str, Any]: + request_token = str(request_id or "").strip() + if not request_token: + return {} + with self.mesh._conn() as conn: + row = conn.execute( + "SELECT * FROM mesh_autonomy_runs WHERE request_id=? LIMIT 1", + (request_token,), + ).fetchone() + return self._row_to_run(row) + + def _run_response(self, run: dict) -> dict[str, Any]: + stored_result = dict(run.get("result") or {}) + helpers = dict(stored_result.get("helpers") or {}) + return { + "status": run.get("status") or "running", + "request_id": run.get("request_id") or "", + "mode": run.get("mode") or "assisted", + "summary": run.get("summary") or "", + "operator_summary": run.get("summary") or "", + "actions": list(run.get("actions") or []), + "routes": stored_result.get("routes") or self.routes_health(limit=24), + "proof": stored_result.get("proof_retry") or stored_result.get("proof") or {}, + "helpers": helpers, + "approvals": helpers.get("approvals") or [], + "run": run, + "result": stored_result, + "generated_at": self._utcnow(), + "deduped": True, + } + + def _record_run( + self, + run_id: str, + *, + request_id: str, + mode: str, + status: str, + summary: str, + actions: list[dict[str, Any]], + result: Optional[dict] = None, + metadata: Optional[dict] = None, + ) -> dict[str, Any]: + now = self._utcnow() + with self.mesh._conn() as conn: + existing = conn.execute( + """ + SELECT id FROM mesh_autonomy_runs + WHERE id=? OR request_id=? + ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END + LIMIT 1 + """, + (run_id, request_id, run_id), + ).fetchone() + if existing is not None: + run_id = existing["id"] + conn.execute( + """ + UPDATE mesh_autonomy_runs + SET mode=?, + status=?, + summary=?, + actions=?, + result=?, + metadata=?, + updated_at=? + WHERE id=? + """, + ( + mode, + status, + summary, + json.dumps(actions), + json.dumps(dict(result or {})), + json.dumps(dict(metadata or {})), + now, + run_id, + ), + ) + else: + conn.execute( + """ + INSERT INTO mesh_autonomy_runs + (id, request_id, mode, status, summary, actions, result, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + run_id, + request_id, + mode, + status, + summary, + json.dumps(actions), + json.dumps(dict(result or {})), + json.dumps(dict(metadata or {})), + now, + now, + ), + ) + conn.commit() + row = conn.execute("SELECT * FROM mesh_autonomy_runs WHERE id=?", (run_id,)).fetchone() + run = self._row_to_run(row) + self._event("mesh.autonomy.run", peer_id=self.mesh.node_id, request_id=request_id, payload=run) + return run + + def status(self) -> dict[str, Any]: + routes = self.routes_health(limit=50) + try: + helper_autonomy = dict(self.mesh.evaluate_autonomous_offload() or {}) + except Exception: + helper_autonomy = {"decision": "noop", "reasons": ["helper_autonomy_unavailable"]} + try: + pressure = dict(self.mesh.mesh_pressure() or {}) + except Exception: + pressure = {"pressure": "unknown", "needs_help": False} + try: + connectivity = dict(self.mesh.connectivity_diagnostics(limit=24) or {}) + except Exception: + connectivity = {"status": "error", "share_advice": "Connectivity diagnostics are unavailable."} + last_run = self.latest_run() + healthy = int(routes.get("healthy") or 0) + route_count = int(routes.get("count") or 0) + if route_count and healthy == route_count: + summary = "Mesh is strong: every known peer has a proven route." + elif healthy: + summary = f"Mesh is usable: {healthy} of {route_count} peer route(s) are proven." + else: + summary = "Autonomic Mesh is ready. Press Activate to discover, repair, enlist, and prove nearby devices." + return { + "status": "ok", + "mode": "assisted", + "peer_id": self.mesh.node_id, + "operator_summary": summary, + "routes": routes, + "pressure": pressure, + "helper_autonomy": helper_autonomy, + "connectivity": connectivity, + "last_run": last_run, + "recommended_actions": self._recommended_actions(routes, connectivity), + "generated_at": self._utcnow(), + } + + def _recommended_actions(self, routes: dict, connectivity: dict) -> list[str]: + actions = [] + if not (routes.get("routes") or []): + actions.append("Scan nearby devices and connect trusted peers.") + if int(routes.get("count") or 0) and int(routes.get("healthy") or 0) < int(routes.get("count") or 0): + actions.append("Probe routes and repair any stale device URL.") + if connectivity.get("share_advice"): + actions.append(str(connectivity.get("share_advice"))) + return actions[:4] + + def _proof_failed_due_transport(self, proof: dict) -> bool: + mission = dict((proof or {}).get("mission") or {}) + metadata = dict(mission.get("metadata") or {}) + text = " ".join( + str(value or "") + for value in [ + proof.get("error"), + metadata.get("launch_error"), + metadata.get("error"), + mission.get("status"), + ] + ).lower() + return "timed out" in text or "timeout" in text or "urlopen" in text or "connection" in text + + def _run_whole_mesh_proof(self, *, include_local: bool, limit: int, request_id: str) -> dict[str, Any]: + return self.mesh.launch_mesh_test_mission(include_local=include_local, limit=limit, request_id=request_id) + + def _repair_routes(self, peer_ids: list[str], *, timeout: float, request_id: str, actions: list[dict[str, Any]]) -> list[dict]: + repairs = [] + for peer_id in peer_ids: + probe = self.probe_routes(peer_id=peer_id, timeout=timeout, limit=4) + repairs.append(probe) + best_route = str(probe.get("best_route") or "").strip() + if best_route: + try: + sync = self.mesh.sync_peer(peer_id, base_url=best_route, limit=20, refresh_manifest=True) + self._action( + actions, + "route_synced", + "ok", + f"Synced {peer_id} through repaired route.", + peer_id=peer_id, + details={"base_url": best_route, "sync": sync}, + request_id=request_id, + ) + except Exception as exc: + self._action( + actions, + "route_sync_failed", + "warning", + f"Route was reachable for {peer_id}, but sync failed: {exc}", + peer_id=peer_id, + details={"base_url": best_route, "error": str(exc)}, + request_id=request_id, + ) + return repairs + + def activate( + self, + *, + mode: str = "assisted", + limit: int = 24, + scan_timeout: float = 0.8, + timeout: float = 3.0, + run_proof: bool = True, + repair: bool = True, + max_enlist: int = 2, + actor_agent_id: str = "ocp-autonomy", + request_id: Optional[str] = None, + ) -> dict[str, Any]: + mode_token = str(mode or "assisted").strip().lower() or "assisted" + request_token = str(request_id or f"autonomic-mesh-{uuid.uuid4().hex[:12]}").strip() + if request_id: + existing_run = self.run_by_request_id(request_token) + if existing_run: + return self._run_response(existing_run) + run_id = str(uuid.uuid4()) + actions: list[dict[str, Any]] = [] + result: dict[str, Any] = {} + self._record_run( + run_id, + request_id=request_token, + mode=mode_token, + status="running", + summary="Autonomic Mesh activation is running.", + actions=actions, + result=result, + metadata={"actor_agent_id": actor_agent_id}, + ) + + try: + diagnostics = self.mesh.connectivity_diagnostics(limit=limit) + result["diagnostics"] = diagnostics + self._action( + actions, + "diagnostics", + "ok", + diagnostics.get("share_advice") or "Checked local IPs and shareable URLs.", + details={"sharing_mode": diagnostics.get("sharing_mode"), "lan_urls": diagnostics.get("lan_urls") or []}, + request_id=request_token, + ) + except Exception as exc: + self._action(actions, "diagnostics", "warning", f"Connectivity diagnostics failed: {exc}", details={"error": str(exc)}, request_id=request_token) + + try: + scan = self.mesh.scan_local_peers(timeout=scan_timeout, limit=limit, trust_tier="trusted") + result["scan"] = scan + self._action( + actions, + "scan", + "ok", + f"Scanned nearby routes: {scan.get('reachable', scan.get('discovered', 0))} candidate(s) surfaced.", + details={"discovered": scan.get("discovered"), "errors": scan.get("errors")}, + request_id=request_token, + ) + except Exception as exc: + self._action(actions, "scan", "warning", f"Nearby scan could not complete: {exc}", details={"error": str(exc)}, request_id=request_token) + + try: + connected = self.mesh.connect_all_devices(timeout=timeout, scan_timeout=scan_timeout, limit=limit, trust_tier="trusted") + result["connect"] = connected + self._action( + actions, + "connect", + "ok", + connected.get("operator_summary") or f"Connected {connected.get('connected', 0)} peer(s).", + details={"connected": connected.get("connected"), "already_connected": connected.get("already_connected"), "errors": connected.get("errors")}, + request_id=request_token, + ) + except Exception as exc: + self._action(actions, "connect", "warning", f"Connect pass had trouble: {exc}", details={"error": str(exc)}, request_id=request_token) + + peer_rows = list(self.mesh.list_peers(limit=max(24, int(limit or 24) * 2)).get("peers") or []) + peer_ids = [str(peer.get("peer_id") or "").strip() for peer in peer_rows if str(peer.get("peer_id") or "").strip()] + route_probes = [] + for peer_id in peer_ids: + probe = self.probe_routes(peer_id=peer_id, timeout=timeout, limit=4) + route_probes.append(probe) + self._action( + actions, + "route_probe", + "ok" if int(probe.get("reachable") or 0) else "warning", + probe.get("operator_summary") or f"Probed routes for {peer_id}.", + peer_id=peer_id, + details={"best_route": probe.get("best_route"), "reachable": probe.get("reachable")}, + request_id=request_token, + ) + result["routes"] = self.routes_health(limit=max(24, int(limit or 24))) + result["route_probes"] = route_probes + + helper_result = self._evaluate_and_enlist_helpers( + actions, + request_id=request_token, + max_enlist=max_enlist, + actor_agent_id=actor_agent_id, + ) + result["helpers"] = helper_result + + proof: dict[str, Any] = {} + if run_proof: + try: + proof = self._run_whole_mesh_proof(include_local=True, limit=limit, request_id=f"{request_token}-proof") + result["proof"] = proof + mission = dict(proof.get("mission") or {}) + mission_status = str(mission.get("status") or proof.get("status") or "unknown") + self._action( + actions, + "whole_mesh_proof", + "ok" if mission_status in {"completed", "planned", "accepted"} else "warning", + f"Whole-mesh proof launched with status {mission_status}.", + details={"mission_id": mission.get("id"), "mission_status": mission_status}, + request_id=request_token, + ) + if repair and self._proof_failed_due_transport(proof): + self._action(actions, "route_repair", "running", "Proof hit a transport timeout; probing routes once before retry.", request_id=request_token) + result["repairs"] = self._repair_routes(peer_ids, timeout=timeout, request_id=request_token, actions=actions) + proof = self._run_whole_mesh_proof(include_local=True, limit=limit, request_id=f"{request_token}-proof-retry") + result["proof_retry"] = proof + retry_mission = dict(proof.get("mission") or {}) + self._action( + actions, + "whole_mesh_proof_retry", + "ok", + f"Retried whole-mesh proof with status {retry_mission.get('status') or proof.get('status') or 'unknown'}.", + details={"mission_id": retry_mission.get("id"), "mission_status": retry_mission.get("status")}, + request_id=request_token, + ) + except Exception as exc: + result["proof_error"] = str(exc) + self._action(actions, "whole_mesh_proof", "warning", f"Whole-mesh proof needs attention: {exc}", details={"error": str(exc)}, request_id=request_token) + + status, summary = self._activation_outcome(result, actions) + run = self._record_run( + run_id, + request_id=request_token, + mode=mode_token, + status=status, + summary=summary, + actions=actions, + result=result, + metadata={"actor_agent_id": actor_agent_id}, + ) + result["run"] = run + try: + self.mesh.publish_notification( + notification_type="mesh.autonomy.summary", + priority="high" if status in {"needs_attention", "failed"} else "normal", + title="Autonomic Mesh activation complete", + body=summary, + compact_title="Autonomic Mesh", + compact_body=summary, + target_peer_id=self.mesh.node_id, + target_agent_id=actor_agent_id, + target_device_classes=["full", "light", "micro"], + metadata={"request_id": request_token, "status": status}, + ) + except Exception: + self.mesh.logger.debug("autonomy summary notification failed", exc_info=True) + return { + "status": status, + "request_id": request_token, + "mode": mode_token, + "summary": summary, + "operator_summary": summary, + "actions": actions, + "routes": result.get("routes") or self.routes_health(limit=limit), + "proof": result.get("proof_retry") or result.get("proof") or {}, + "helpers": helper_result, + "approvals": helper_result.get("approvals") or [], + "run": run, + "result": result, + "generated_at": self._utcnow(), + } + + def _evaluate_and_enlist_helpers( + self, + actions: list[dict[str, Any]], + *, + request_id: str, + max_enlist: int, + actor_agent_id: str, + ) -> dict[str, Any]: + try: + plan = self.mesh.plan_helper_enlistment( + job={ + "kind": "python.inline", + "requirements": {"capabilities": ["python"], "placement": {"workload_class": "connectivity_test"}}, + "policy": {"classification": "trusted", "mode": "batch"}, + }, + limit=max(1, int(max_enlist or 2)) * 3, + ) + except Exception as exc: + self._action(actions, "helper_plan", "warning", f"Helper planning failed: {exc}", details={"error": str(exc)}, request_id=request_id) + return {"status": "error", "error": str(exc), "plan": {}, "enlisted": [], "approvals": [], "skipped": []} + + enlisted = [] + approvals = [] + skipped = [] + self._action( + actions, + "helper_plan", + "ok", + f"Evaluated {plan.get('candidate_count', 0)} helper candidate(s).", + details={"candidate_count": plan.get("candidate_count")}, + request_id=request_id, + ) + candidates = list(plan.get("candidates") or []) + known_candidate_ids = {str(candidate.get("peer_id") or "").strip() for candidate in candidates} + for peer in list(self.mesh.list_peers(limit=100).get("peers") or []): + peer_id = str(peer.get("peer_id") or "").strip() + trust = self._normalize_trust_tier(peer.get("trust_tier") or "trusted") + if not peer_id or peer_id in known_candidate_ids or trust in {"blocked", "public"}: + continue + metadata = dict(peer.get("metadata") or {}) + route_health = dict(metadata.get("route_health") or {}) + route_status = str(route_health.get("status") or "").strip().lower() + has_proven_route = bool(metadata.get("last_reachable_base_url")) or route_status == "reachable" + if route_status == "unreachable" or not has_proven_route: + continue + device_profile = dict(peer.get("device_profile") or {}) + compute_profile = dict(device_profile.get("compute_profile") or {}) + try: + enlistment = self.mesh.helpers.peer_enlistment_state(peer) + except Exception: + enlistment = dict((peer.get("metadata") or {}).get("enlistment") or {"state": "unenlisted"}) + candidates.append( + { + "peer_id": peer_id, + "display_name": peer.get("display_name") or peer_id, + "trust_tier": trust, + "score": 0, + "enlistment": enlistment, + "device_class": device_profile.get("device_class") or "full", + "execution_tier": device_profile.get("execution_tier") or "standard", + "compute_profile": compute_profile, + "reasons": ["known_connected_peer"], + "recommended_action": "enlist", + } + ) + known_candidate_ids.add(peer_id) + + for candidate in candidates: + peer_id = str(candidate.get("peer_id") or "").strip() + if not peer_id: + continue + peer = self.mesh._row_to_peer(self.mesh._get_peer_row(peer_id)) or {} + if not self._route_is_usable(peer): + skipped.append({"peer_id": peer_id, "reason": "route_not_usable"}) + self._action(actions, "helper_skipped", "warning", f"Did not enlist {peer_id} because no fresh working route is proven.", peer_id=peer_id, request_id=request_id) + continue + trust = self._normalize_trust_tier(candidate.get("trust_tier") or "trusted") + device_class = str(candidate.get("device_class") or "full").strip().lower() + role = "gpu_helper" if dict(candidate.get("compute_profile") or {}).get("gpu_capable") else "helper" + if str((candidate.get("enlistment") or {}).get("state") or "").strip().lower() == "enlisted": + skipped.append({"peer_id": peer_id, "reason": "already_enlisted"}) + self._action(actions, "helper_reuse", "ok", f"{candidate.get('display_name') or peer_id} is already enlisted.", peer_id=peer_id, request_id=request_id) + continue + if len(enlisted) >= max(0, int(max_enlist or 0)): + skipped.append({"peer_id": peer_id, "reason": "max_enlist_reached"}) + continue + if trust == "trusted" and device_class == "full": + try: + state = self.mesh.enlist_helper(peer_id, mode="on_demand", role=role, reason="autonomic_mesh_activation", source="autonomy") + enlisted.append({"peer_id": peer_id, "state": state}) + self._action(actions, "helper_enlisted", "ok", f"Enlisted {candidate.get('display_name') or peer_id} as a safe helper.", peer_id=peer_id, details={"role": role}, request_id=request_id) + except Exception as exc: + skipped.append({"peer_id": peer_id, "reason": str(exc)}) + self._action(actions, "helper_enlist_failed", "warning", f"Could not enlist {peer_id}: {exc}", peer_id=peer_id, details={"error": str(exc)}, request_id=request_id) + elif trust == "partner": + approval = self.mesh.create_approval_request( + title=f"Allow {candidate.get('display_name') or peer_id} to help this mesh?", + summary="Autonomic Mesh found a partner peer that could help, but partner devices need approval before helper enlistment.", + action_type="autonomic.helper.enlist", + severity="normal", + request_id=f"{request_id}-helper-{peer_id}", + requested_by_peer_id=self.mesh.node_id, + requested_by_agent_id=actor_agent_id, + target_peer_id=self.mesh.node_id, + target_agent_id=actor_agent_id, + target_device_classes=["full", "light", "micro"], + metadata={ + "autonomic_mesh": True, + "candidate_peer_id": peer_id, + "autonomous_offload": { + "peer_ids": [peer_id], + "mode": "on_demand", + "role": role, + "workload_class": "connectivity_test", + "max_auto_enlist": 1, + }, + }, + ) + approvals.append(approval) + self._action(actions, "helper_approval_requested", "approval_required", f"Asked before using partner peer {candidate.get('display_name') or peer_id}.", peer_id=peer_id, details={"approval": approval}, request_id=request_id) + else: + skipped.append({"peer_id": peer_id, "reason": f"trust_tier_{trust}_not_auto_enlisted"}) + self._action(actions, "helper_skipped", "blocked", f"Did not auto-enlist {peer_id} because trust tier is {trust}.", peer_id=peer_id, request_id=request_id) + return { + "status": "ok", + "plan": plan, + "enlisted": enlisted, + "approvals": approvals, + "skipped": skipped, + } + + def _activation_outcome(self, result: dict, actions: list[dict[str, Any]]) -> tuple[str, str]: + routes = dict(result.get("routes") or {}) + healthy = int(routes.get("healthy") or 0) + route_count = int(routes.get("count") or 0) + proof = dict(result.get("proof_retry") or result.get("proof") or {}) + mission = dict(proof.get("mission") or {}) + proof_status = str(mission.get("status") or proof.get("status") or "").strip().lower() + warnings = [action for action in actions if action.get("status") in {"warning", "blocked"}] + approvals = list(((result.get("helpers") or {}).get("approvals") or [])) + if result.get("proof_error") and healthy == 0: + return "needs_attention", "Autonomic Mesh found peers, but proof execution still needs attention." + if approvals: + return "approval_requested", "Mesh routes are prepared; one or more partner helpers need approval before OCP can use them." + if route_count and healthy == route_count and (not proof or proof_status in {"completed", "planned", "accepted", "ok"}): + return "completed", f"Mesh is strong: {healthy} route(s) proven, helpers evaluated, and whole-mesh proof launched." + if healthy: + return "partial", f"Mesh is partly healthy: {healthy} route(s) work, but {len(warnings)} item(s) need attention." + return "needs_attention", "Autonomic Mesh could not prove a working remote route yet. Check Wi-Fi, firewall, or the peer URL." diff --git a/mesh_execution/service.py b/mesh_execution/service.py index ac847f1..d25e373 100644 --- a/mesh_execution/service.py +++ b/mesh_execution/service.py @@ -110,9 +110,23 @@ def build_runtime_env(self, *, job: dict, payload: dict, spec: dict) -> tuple[di execution = dict(spec.get("execution") or {}) runtime_environment = dict(spec.get("runtime_environment") or {}) env_policy = dict(runtime_environment.get("env_policy") or {}) - inherit_host_env = bool(env_policy.get("inherit_host_env", True)) + inherit_host_env = bool(env_policy.get("inherit_host_env", False)) allow_env_override = bool(env_policy.get("allow_env_override", True)) - env: dict[str, str] = dict(os.environ) if inherit_host_env else {} + raw_inherit_allowlist = env_policy.get("inherit_env_allowlist") or [] + if isinstance(raw_inherit_allowlist, str): + raw_inherit_allowlist = [item.strip() for item in raw_inherit_allowlist.split(",")] + inherit_allowlist = [ + self._normalize_env_var_name(item) + for item in list(raw_inherit_allowlist or []) + if str(item or "").strip() + ] + env: dict[str, str] = {} + if inherit_host_env: + env = { + key: str(os.environ[key]) + for key in inherit_allowlist + if key in os.environ + } delivery_records: list[dict] = [] for key, value in dict(execution.get("env") or {}).items(): env_name = self._normalize_env_var_name(key) @@ -140,6 +154,35 @@ def build_runtime_env(self, *, job: dict, payload: dict, spec: dict) -> tuple[di delivery_records.append(delivery_record) return env, delivery_records + def _redact_env_assignment(self, assignment: Any) -> str: + key, separator, _ = str(assignment or "").partition("=") + if separator: + return f"{key}=" + return "" + + def _redact_env_vector(self, argv: list[str], *, flags: set[str]) -> list[str]: + redacted: list[str] = [] + redact_next = False + for token in argv: + if redact_next: + redacted.append(self._redact_env_assignment(token)) + redact_next = False + continue + sample = str(token) + matched_inline = False + for flag in flags: + prefix = f"{flag}=" + if sample.startswith(prefix): + redacted.append(f"{prefix}{self._redact_env_assignment(sample[len(prefix):])}") + matched_inline = True + break + if matched_inline: + continue + redacted.append(sample) + if sample in flags: + redact_next = True + return redacted + def container_runtime_paths(self, runtime_environment: dict, execution: dict) -> dict[str, Any]: filesystem = dict(runtime_environment.get("filesystem") or {}) profile = str(filesystem.get("profile") or "workspace").strip().lower() or "workspace" @@ -624,7 +667,7 @@ def execute_job(self, job: dict, *, payload: dict) -> tuple[str, dict, dict]: "image": image, "command": [str(part) for part in (execution.get("command") or [])], "args": [str(part) for part in (execution.get("args") or [])], - "docker_argv": docker_argv, + "docker_argv": self._redact_env_vector(docker_argv, flags={"-e", "--env"}), "container_name": container_name, "network_mode": network_mode, "mounted_workspace": bool(path_info["mount_workspace"]), @@ -687,7 +730,7 @@ def execute_job(self, job: dict, *, payload: dict) -> tuple[str, dict, dict]: "component_path": str(component_path), "entrypoint": entrypoint, "args": [str(part) for part in (execution.get("args") or [])], - "wasm_argv": wasm_argv, + "wasm_argv": self._redact_env_vector(wasm_argv, flags={"--env"}), "network_mode": network_mode, "preopened_dir": "" if str(filesystem.get("profile") or "workspace").strip().lower() == "isolated" else str(cwd_path), "cwd": str(cwd_path), diff --git a/mesh_protocol/conformance.py b/mesh_protocol/conformance.py index 569dd7c..c9b344b 100644 --- a/mesh_protocol/conformance.py +++ b/mesh_protocol/conformance.py @@ -39,7 +39,7 @@ def build_protocol_conformance_snapshot() -> dict[str, Any]: "protocol_release": "0.1", "implementation": "Sovereign Mesh", "protocol_version": "sovereign-mesh/v1", - "signature_scheme": "ed25519", + "signature_scheme": "ed25519-sha512-v1", "signature": "fixture-signature", }, "body": {"artifact": {"descriptor": {"artifact_id": "artifact-fixture", "digest": "f00d"}}}, @@ -130,6 +130,114 @@ def build_protocol_conformance_snapshot() -> dict[str, Any]: "metadata": {"source": "contract-fixture"}, }, ), + _fixture_entry( + "route-health-reachable", + schema_ref="RouteHealth", + purpose="A proven HTTP route for a nearby trusted peer.", + value={ + "peer_id": "beta-node", + "display_name": "Beta", + "status": "reachable", + "best_route": "http://192.168.1.22:8421", + "last_reachable_base_url": "http://192.168.1.22:8421", + "checked_at": "2026-01-01T00:00:00Z", + "last_success_at": "2026-01-01T00:00:00Z", + "last_error": "", + "freshness": "fresh", + "age_seconds": 0, + "failure_count": 0, + "next_probe_after": "", + "operator_hint": "", + "operator_summary": "Beta is reachable at http://192.168.1.22:8421.", + "candidates": [ + { + "base_url": "http://192.168.1.22:8421", + "source": "last_reachable", + "status": "reachable", + "latency_ms": 12, + "checked_at": "2026-01-01T00:00:00Z", + "last_success_at": "2026-01-01T00:00:00Z", + "last_error": "", + "freshness": "fresh", + "failure_count": 0, + "next_probe_after": "", + "operator_hint": "", + } + ], + }, + ), + _fixture_entry( + "autonomic-activate-request", + schema_ref="AutonomicActivateRequest", + purpose="Assisted one-button mesh activation request from the phone control surface.", + value={ + "mode": "assisted", + "limit": 24, + "scan_timeout": 0.8, + "timeout": 3.0, + "run_proof": True, + "repair": True, + "max_enlist": 2, + "actor_agent_id": "ocp-mobile-ui", + "request_id": "autonomic-fixture", + }, + ), + _fixture_entry( + "app-status-operator-home", + schema_ref="AppStatus", + purpose="Compact operator-facing status used by the installable OCP app home.", + value={ + "status": "ok", + "node": { + "node_id": "alpha-node", + "display_name": "Alpha", + "device_class": "full", + "form_factor": "laptop", + "protocol_release": "0.1", + "protocol_version": "sovereign-mesh/v1", + }, + "app_urls": { + "base_url": "http://192.168.1.10:8421", + "app_url": "http://192.168.1.10:8421/app", + "setup_url": "http://192.168.1.10:8421/easy", + "control_url": "http://192.168.1.10:8421/control", + "phone_url": "http://192.168.1.10:8421/app", + "lan_urls": ["http://192.168.1.10:8421/app"], + "sharing_mode": "lan", + "share_advice": "", + }, + "mesh_quality": { + "status": "strong", + "label": "Mesh strong", + "peer_count": 1, + "route_count": 1, + "healthy_routes": 1, + "operator_summary": "Mesh is strong.", + }, + "autonomy": {"status": "ok", "mode": "assisted", "operator_summary": "Mesh is strong."}, + "route_health": { + "status": "ok", + "count": 1, + "healthy": 1, + "routes": [ + { + "peer_id": "beta-node", + "status": "reachable", + "candidates": [], + } + ], + }, + "latest_proof": { + "status": "completed", + "mission_id": "mission-fixture", + "title": "Whole Mesh Test Mission", + "summary": "Whole Mesh Test Mission is completed.", + }, + "approvals": {"pending_count": 0, "items": [], "operator_summary": "No approvals are waiting."}, + "next_actions": ["Mesh is ready."], + "generated_at": "2026-01-01T00:00:00Z", + }, + ), _fixture_entry( "contract-snapshot-minimal", schema_ref="ContractSnapshot", diff --git a/mesh_protocol/schemas.py b/mesh_protocol/schemas.py index 9eeb4eb..87079bd 100644 --- a/mesh_protocol/schemas.py +++ b/mesh_protocol/schemas.py @@ -268,6 +268,186 @@ "metadata": {"type": "object"}, }, }, + "RouteCandidate": { + "type": "object", + "required": ["base_url", "source", "status"], + "properties": { + "base_url": {"type": "string"}, + "source": {"type": "string"}, + "status": {"type": "string"}, + "latency_ms": {"type": "any"}, + "checked_at": {"type": "string"}, + "last_success_at": {"type": "string"}, + "last_error": {"type": "string"}, + "observed_peer_id": {"type": "string"}, + "freshness": {"type": "string"}, + "failure_count": {"type": "integer"}, + "next_probe_after": {"type": "string"}, + "operator_hint": {"type": "string"}, + }, + }, + "RouteHealth": { + "type": "object", + "required": ["peer_id", "status", "candidates"], + "properties": { + "peer_id": {"type": "string"}, + "display_name": {"type": "string"}, + "status": {"type": "string"}, + "best_route": {"type": "string"}, + "last_reachable_base_url": {"type": "string"}, + "checked_at": {"type": "string"}, + "last_success_at": {"type": "string"}, + "last_error": {"type": "string"}, + "freshness": {"type": "string"}, + "age_seconds": {"type": "any"}, + "failure_count": {"type": "integer"}, + "next_probe_after": {"type": "string"}, + "operator_hint": {"type": "string"}, + "operator_summary": {"type": "string"}, + "candidates": {"type": "array", "items": {"$ref": "#/schemas/RouteCandidate"}}, + }, + }, + "RouteHealthList": { + "type": "object", + "required": ["status", "routes"], + "properties": { + "status": {"type": "string"}, + "peer_id": {"type": "string"}, + "count": {"type": "integer"}, + "healthy": {"type": "integer"}, + "routes": {"type": "array", "items": {"$ref": "#/schemas/RouteHealth"}}, + "operator_summary": {"type": "string"}, + "generated_at": {"type": "string"}, + }, + }, + "RouteProbeRequest": { + "type": "object", + "properties": { + "peer_id": {"type": "string"}, + "base_url": {"type": "string"}, + "timeout": {"type": "number"}, + "limit": {"type": "integer"}, + }, + }, + "RouteProbeResult": { + "type": "object", + "required": ["status"], + "properties": { + "status": {"type": "string"}, + "peer_id": {"type": "string"}, + "checked": {"type": "integer"}, + "reachable": {"type": "integer"}, + "best_route": {"type": "string"}, + "count": {"type": "integer"}, + "results": {"type": "array", "items": {"type": "object"}}, + "candidates": {"type": "array", "items": {"$ref": "#/schemas/RouteCandidate"}}, + "operator_hint": {"type": "string"}, + "operator_summary": {"type": "string"}, + "generated_at": {"type": "string"}, + }, + }, + "AutonomicAction": { + "type": "object", + "required": ["kind", "status", "summary", "created_at"], + "properties": { + "id": {"type": "string"}, + "kind": {"type": "string"}, + "status": {"type": "string"}, + "summary": {"type": "string"}, + "peer_id": {"type": "string"}, + "details": {"type": "object"}, + "created_at": {"type": "string"}, + }, + }, + "AutonomicActivateRequest": { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "limit": {"type": "integer"}, + "scan_timeout": {"type": "number"}, + "timeout": {"type": "number"}, + "run_proof": {"type": "boolean"}, + "repair": {"type": "boolean"}, + "max_enlist": {"type": "integer"}, + "actor_agent_id": {"type": "string"}, + "request_id": {"type": "string"}, + }, + }, + "AutonomicRun": { + "type": "object", + "required": ["status", "summary", "actions"], + "properties": { + "status": {"type": "string"}, + "request_id": {"type": "string"}, + "mode": {"type": "string"}, + "summary": {"type": "string"}, + "operator_summary": {"type": "string"}, + "actions": {"type": "array", "items": {"$ref": "#/schemas/AutonomicAction"}}, + "routes": {"$ref": "#/schemas/RouteHealthList"}, + "proof": {"type": "object"}, + "helpers": {"type": "object"}, + "approvals": {"type": "array", "items": {"type": "object"}}, + "run": {"type": "object"}, + "result": {"type": "object"}, + "generated_at": {"type": "string"}, + }, + }, + "AutonomicMeshStatus": { + "type": "object", + "required": ["status", "mode", "routes"], + "properties": { + "status": {"type": "string"}, + "mode": {"type": "string"}, + "peer_id": {"type": "string"}, + "operator_summary": {"type": "string"}, + "routes": {"$ref": "#/schemas/RouteHealthList"}, + "pressure": {"type": "object"}, + "helper_autonomy": {"type": "object"}, + "connectivity": {"type": "object"}, + "last_run": {"type": "object"}, + "recommended_actions": {"type": "array", "items": {"type": "string"}}, + "generated_at": {"type": "string"}, + }, + }, + "AppStatus": { + "type": "object", + "description": "Operator-facing compact status for the installable OCP app home.", + "required": ["status", "node", "app_urls", "mesh_quality", "next_actions"], + "properties": { + "status": {"type": "string"}, + "node": {"type": "object"}, + "app_urls": { + "type": "object", + "properties": { + "base_url": {"type": "string"}, + "app_url": {"type": "string"}, + "setup_url": {"type": "string"}, + "control_url": {"type": "string"}, + "phone_url": {"type": "string"}, + "lan_urls": {"type": "array", "items": {"type": "string"}}, + "sharing_mode": {"type": "string"}, + "share_advice": {"type": "string"}, + }, + }, + "mesh_quality": { + "type": "object", + "properties": { + "status": {"type": "string"}, + "label": {"type": "string"}, + "peer_count": {"type": "integer"}, + "route_count": {"type": "integer"}, + "healthy_routes": {"type": "integer"}, + "operator_summary": {"type": "string"}, + }, + }, + "autonomy": {"type": "object"}, + "route_health": {"$ref": "#/schemas/RouteHealthList"}, + "latest_proof": {"type": "object"}, + "approvals": {"type": "object"}, + "next_actions": {"type": "array", "items": {"type": "string"}}, + "generated_at": {"type": "string"}, + }, + }, "PeerAdvisory": { "type": "object", "properties": { diff --git a/mesh_scheduler/service.py b/mesh_scheduler/service.py index b9b0208..e69aa5a 100644 --- a/mesh_scheduler/service.py +++ b/mesh_scheduler/service.py @@ -509,6 +509,95 @@ def local_candidate_score(self, job: dict) -> tuple[int, list[str], dict]: reasons.append("execution_class_latency") return score, reasons + ["inline_capable"], continuity_alignment + def _route_health_score(self, peer: dict) -> tuple[int, list[str]]: + metadata = dict(peer.get("metadata") or {}) + route_health = dict(metadata.get("route_health") or {}) + status = str(route_health.get("status") or "").strip().lower() + freshness = str(route_health.get("freshness") or "").strip().lower() + failure_count = int(route_health.get("failure_count") or 0) + score = 0 + reasons: list[str] = [] + if metadata.get("last_reachable_base_url"): + score += 45 + reasons.append("route_last_reachable") + if status == "reachable": + if freshness == "stale": + score -= 60 + reasons.append("route_probe_stale") + elif freshness == "aging": + score += 10 + reasons.append("route_probe_aging") + else: + score += 30 + reasons.append("route_probe_reachable") + elif status == "unreachable": + score -= 180 + min(120, failure_count * 20) + reasons.append("route_probe_unreachable") + if failure_count: + reasons.append(f"route_failure_count={failure_count}") + return score, reasons + + def _locality_tokens(self, value) -> set[str]: + tokens: set[str] = set() + + def collect(item) -> None: + if isinstance(item, dict): + for key in ("digest", "artifact_id", "id", "checkpoint_id"): + token = str(item.get(key) or "").strip() + if token: + tokens.add(token) + for nested in item.values(): + collect(nested) + elif isinstance(item, list): + for nested in item: + collect(nested) + elif isinstance(item, str): + token = item.strip() + if token.startswith(("sha256:", "artifact-", "checkpoint-")): + tokens.add(token) + + collect(value) + return tokens + + def _job_locality_tokens(self, job: dict) -> tuple[set[str], set[str]]: + artifact_tokens = self._locality_tokens(job.get("artifact_inputs") or []) + metadata = dict(job.get("metadata") or {}) + artifact_tokens.update(self._locality_tokens(job.get("payload_ref") or {})) + artifact_tokens.update(self._locality_tokens(metadata.get("artifact_refs") or [])) + checkpoint_tokens = set() + checkpoint_tokens.update(self._locality_tokens(metadata.get("latest_checkpoint_ref") or {})) + checkpoint_tokens.update(self._locality_tokens(metadata.get("resume_checkpoint_ref") or {})) + checkpoint_tokens.update(self._locality_tokens(dict(job.get("continuity") or {}).get("latest_checkpoint_ref") or {})) + return artifact_tokens, checkpoint_tokens + + def _peer_locality_tokens(self, peer: dict) -> tuple[set[str], set[str]]: + metadata = dict(peer.get("metadata") or {}) + artifact_tokens = set() + checkpoint_tokens = set() + for key in ("artifact_inventory", "artifact_locality", "cached_artifacts"): + artifact_tokens.update(self._locality_tokens(metadata.get(key) or {})) + for key in ("checkpoint_inventory", "checkpoint_locality", "cached_checkpoints"): + checkpoint_tokens.update(self._locality_tokens(metadata.get(key) or {})) + checkpoint_tokens.update(self._locality_tokens(metadata.get("latest_checkpoint_ref") or {})) + return artifact_tokens, checkpoint_tokens + + def _locality_score(self, peer: dict, job: dict) -> tuple[int, list[str]]: + job_artifacts, job_checkpoints = self._job_locality_tokens(job) + if not job_artifacts and not job_checkpoints: + return 0, [] + peer_artifacts, peer_checkpoints = self._peer_locality_tokens(peer) + artifact_matches = job_artifacts & peer_artifacts + checkpoint_matches = job_checkpoints & (peer_artifacts | peer_checkpoints) + score = 0 + reasons: list[str] = [] + if artifact_matches: + score += min(4, len(artifact_matches)) * 35 + reasons.append(f"artifact_locality_match={len(artifact_matches)}") + if checkpoint_matches: + score += min(3, len(checkpoint_matches)) * 80 + reasons.append(f"checkpoint_locality_match={len(checkpoint_matches)}") + return score, reasons + def peer_candidate_score(self, peer: dict, job: dict) -> tuple[int, list[str], dict]: requirements = dict(job.get("requirements") or {}) policy = self.mesh._normalize_policy(job.get("policy") or {}) @@ -582,6 +671,12 @@ def peer_candidate_score(self, peer: dict, job: dict) -> tuple[int, list[str], d if peer.get("status") == "connected": score += 40 reasons.append("connected") + route_score, route_reasons = self._route_health_score(peer) + score += route_score + reasons.extend(route_reasons) + locality_score, locality_reasons = self._locality_score(peer, job) + score += locality_score + reasons.extend(locality_reasons) if peer.get("heartbeat", {}).get("status") == "active": score += 30 reasons.append("active_heartbeat") diff --git a/mesh_state/schema.py b/mesh_state/schema.py index f2eb8a5..b76927f 100644 --- a/mesh_state/schema.py +++ b/mesh_state/schema.py @@ -308,6 +308,18 @@ updated_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (peer_id, workload_class) ); +CREATE TABLE IF NOT EXISTS mesh_autonomy_runs ( + id TEXT PRIMARY KEY, + request_id TEXT UNIQUE, + mode TEXT DEFAULT 'assisted', + status TEXT DEFAULT 'planned', + summary TEXT DEFAULT '', + actions TEXT DEFAULT '[]', + result TEXT DEFAULT '{}', + metadata TEXT DEFAULT '{}', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP +); CREATE INDEX IF NOT EXISTS idx_mesh_events_created ON mesh_events(created_at DESC); CREATE INDEX IF NOT EXISTS idx_mesh_remote_events_peer_created ON mesh_remote_events(peer_id, remote_seq DESC); CREATE INDEX IF NOT EXISTS idx_mesh_leases_peer_status ON mesh_leases(peer_id, status); @@ -326,6 +338,7 @@ CREATE INDEX IF NOT EXISTS idx_mesh_queue_messages_dedupe ON mesh_queue_messages(dedupe_key, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_mesh_scheduler_decisions_created ON mesh_scheduler_decisions(created_at DESC); CREATE INDEX IF NOT EXISTS idx_mesh_offload_preferences_updated ON mesh_offload_preferences(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_mesh_autonomy_runs_created ON mesh_autonomy_runs(created_at DESC); """ diff --git a/ocp_desktop/__init__.py b/ocp_desktop/__init__.py new file mode 100644 index 0000000..a9714ca --- /dev/null +++ b/ocp_desktop/__init__.py @@ -0,0 +1,3 @@ +"""Desktop launcher helpers for the OCP reference app.""" + +__all__ = [] diff --git a/ocp_desktop/launcher.py b/ocp_desktop/launcher.py new file mode 100644 index 0000000..67d7047 --- /dev/null +++ b/ocp_desktop/launcher.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import argparse +import os +import secrets +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +import webbrowser +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import ocp_startup + +APP_NAME = "OCP" +LOCAL_MODE = "local" +MESH_MODE = "mesh" + +DEFAULT_CONFIG: dict[str, Any] = { + "port": 8421, + "node_id": "", + "display_name": "OCP Node", + "device_class": "full", + "form_factor": "workstation", + "operator_token": "", +} + + +@dataclass(frozen=True) +class LaunchPlan: + mode: str + profile: ocp_startup.StartupProfile + command: list[str] + app_url: str + manifest_url: str + share_urls: list[str] + config_path: Path + + def as_dict(self) -> dict[str, Any]: + return { + "mode": self.mode, + "host": self.profile.host, + "port": self.profile.port, + "node_id": self.profile.node_id, + "display_name": self.profile.display_name, + "command": list(self.command), + "app_url": self.app_url, + "manifest_url": self.manifest_url, + "share_urls": list(self.share_urls), + "config_path": str(self.config_path), + "db_path": str(self.profile.db_path), + "identity_dir": str(self.profile.identity_dir), + "workspace_root": str(self.profile.workspace_root), + "operator_auth_required": self.mode == MESH_MODE, + } + + +def launcher_config_path(*, home: Path | None = None) -> Path: + return ocp_startup.default_launcher_config_path(home=home) + + +def load_launcher_config(path: Path | None = None) -> dict[str, Any]: + config = dict(DEFAULT_CONFIG) + config.update(ocp_startup.read_json_file(path or launcher_config_path(), default={})) + return normalize_launcher_config(config) + + +def save_launcher_config(config: dict[str, Any], path: Path | None = None) -> dict[str, Any]: + normalized = normalize_launcher_config(config) + ocp_startup.write_json_file(path or launcher_config_path(), normalized) + return normalized + + +def normalize_launcher_config(config: dict[str, Any]) -> dict[str, Any]: + payload = dict(DEFAULT_CONFIG) + payload.update(dict(config or {})) + payload["port"] = int(payload.get("port") or 8421) + payload["node_id"] = str(payload.get("node_id") or "").strip() or ocp_startup.default_node_id() + payload["display_name"] = str(payload.get("display_name") or "OCP Node").strip() or "OCP Node" + payload["device_class"] = str(payload.get("device_class") or "full").strip() or "full" + payload["form_factor"] = str(payload.get("form_factor") or "workstation").strip() or "workstation" + payload["operator_token"] = str(payload.get("operator_token") or "").strip() + return payload + + +def operator_app_url(base_url: str, operator_token: str = "") -> str: + base = str(base_url or "").strip().rstrip("/") + if not base: + return "" + app_url = base if base.endswith("/app") else f"{base}/app" + token = str(operator_token or "").strip() + if not token: + return app_url + return f"{app_url}#ocp_operator_token={urllib.parse.quote(token, safe='')}" + + +def build_launch_plan( + mode: str, + config: dict[str, Any] | None, + repo_root: Path, + *, + config_path: Path | None = None, + home: Path | None = None, + create_paths: bool = False, +) -> LaunchPlan: + mode_token = MESH_MODE if str(mode or "").strip().lower() == MESH_MODE else LOCAL_MODE + normalized = normalize_launcher_config(config or {}) + host = "0.0.0.0" if mode_token == MESH_MODE else "127.0.0.1" + state_dir = ocp_startup.default_launcher_state_dir(home=home) + profile = ocp_startup.profile_from_values( + repo_root, + host=host, + port=int(normalized["port"]), + node_id=str(normalized["node_id"]), + display_name=str(normalized["display_name"]), + device_class=str(normalized["device_class"]), + form_factor=str(normalized["form_factor"]), + state_dir=state_dir, + create_paths=create_paths, + ) + return LaunchPlan( + mode=mode_token, + profile=profile, + command=ocp_startup.server_command(profile, repo_root), + app_url=ocp_startup.build_open_url(host, profile.port, "/"), + manifest_url=ocp_startup.health_url(host, profile.port), + share_urls=ocp_startup.share_urls_for_host(host, profile.port), + config_path=config_path or launcher_config_path(home=home), + ) + + +def server_is_alive(plan: LaunchPlan, *, timeout: float = 0.75) -> bool: + try: + with urllib.request.urlopen(plan.manifest_url, timeout=timeout) as response: + return response.status == 200 + except (OSError, urllib.error.URLError): + return False + + +class OCPLauncherApp: + def __init__(self, root, *, repo_root: Path, config_path: Path | None = None): + import tkinter as tk + from tkinter import ttk + + self.tk = tk + self.ttk = ttk + self.root = root + self.repo_root = Path(repo_root) + self.config_path = config_path or launcher_config_path() + self.config = load_launcher_config(self.config_path) + self.process: subprocess.Popen | None = None + self.current_plan: LaunchPlan | None = None + self.closing = False + + root.title("OCP Launcher") + root.geometry("720x560") + root.minsize(620, 500) + root.protocol("WM_DELETE_WINDOW", self.close) + + self.display_name = tk.StringVar(value=str(self.config["display_name"])) + self.node_id = tk.StringVar(value=str(self.config["node_id"])) + self.port = tk.StringVar(value=str(self.config["port"])) + self.status = tk.StringVar(value="OCP is stopped.") + self.urls = tk.StringVar(value="Start Mesh Mode to get a phone/LAN link.") + self.firewall_hint = tk.StringVar( + value="Mesh Mode binds to your LAN. If another device cannot connect, allow Python/OCP through the macOS firewall." + ) + + self._build_ui() + self._poll_status() + + def _build_ui(self) -> None: + tk = self.tk + ttk = self.ttk + frame = ttk.Frame(self.root, padding=18) + frame.pack(fill="both", expand=True) + + ttk.Label(frame, text="Open Compute Protocol", font=("Helvetica", 22, "bold")).pack(anchor="w") + ttk.Label( + frame, + text="Start a local-first OCP node, share it with your phone, and activate the Autonomic Mesh.", + wraplength=640, + ).pack(anchor="w", pady=(4, 18)) + + fields = ttk.LabelFrame(frame, text="Node profile", padding=12) + fields.pack(fill="x") + self._field(fields, "Display name", self.display_name, 0) + self._field(fields, "Node id", self.node_id, 1) + self._field(fields, "Port", self.port, 2) + + actions = ttk.Frame(frame) + actions.pack(fill="x", pady=16) + ttk.Button(actions, text="Start Mesh Mode", command=self.start_mesh_mode).pack(side="left", padx=(0, 8)) + ttk.Button(actions, text="Start Local Only", command=self.start_local_mode).pack(side="left", padx=(0, 8)) + ttk.Button(actions, text="Restart", command=self.restart).pack(side="left", padx=(0, 8)) + ttk.Button(actions, text="Stop", command=self.stop).pack(side="left", padx=(0, 8)) + ttk.Button(actions, text="Open App", command=self.open_app).pack(side="left", padx=(0, 8)) + ttk.Button(actions, text="Copy Phone Link", command=self.copy_phone_link).pack(side="left") + + status_box = ttk.LabelFrame(frame, text="Status", padding=12) + status_box.pack(fill="both", expand=True) + ttk.Label(status_box, textvariable=self.status, wraplength=640).pack(anchor="w") + ttk.Separator(status_box).pack(fill="x", pady=10) + ttk.Label(status_box, text="Links", font=("Helvetica", 13, "bold")).pack(anchor="w") + ttk.Label(status_box, textvariable=self.urls, wraplength=640, justify="left").pack(anchor="w", pady=(4, 10)) + ttk.Label(status_box, textvariable=self.firewall_hint, wraplength=640, foreground="#8a5a00").pack(anchor="w") + + def _field(self, parent, label: str, variable, row: int) -> None: + ttk = self.ttk + ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", pady=4) + entry = ttk.Entry(parent, textvariable=variable) + entry.grid(row=row, column=1, sticky="ew", pady=4, padx=(12, 0)) + parent.columnconfigure(1, weight=1) + + def _current_config(self) -> dict[str, Any]: + config = dict(self.config) + config.update( + { + "display_name": self.display_name.get(), + "node_id": self.node_id.get(), + "port": self.port.get(), + } + ) + self.config = save_launcher_config(config, self.config_path) + return self.config + + def _operator_token_for_mode(self, mode: str) -> str: + if mode != MESH_MODE: + return "" + config = dict(self.config) + token = str(config.get("operator_token") or "").strip() + if not token: + token = secrets.token_urlsafe(24) + config["operator_token"] = token + self.config = save_launcher_config(config, self.config_path) + return token + + def _start(self, mode: str) -> None: + if self.process and self.process.poll() is None: + self.status.set("OCP is already running.") + return + config = self._current_config() + plan = build_launch_plan(mode, config, self.repo_root, config_path=self.config_path, create_paths=True) + ocp_startup.ensure_state_paths(plan.profile) + self.current_plan = plan + env = os.environ.copy() + operator_token = self._operator_token_for_mode(plan.mode) + if operator_token: + env["OCP_OPERATOR_TOKEN"] = operator_token + self.process = subprocess.Popen(plan.command, cwd=str(self.repo_root), env=env) + self.status.set(f"Starting OCP in {plan.mode} mode...") + self._render_links(plan) + + def start_mesh_mode(self) -> None: + self._start(MESH_MODE) + + def start_local_mode(self) -> None: + self._start(LOCAL_MODE) + + def stop(self) -> None: + if not self.process or self.process.poll() is not None: + self.status.set("OCP is already stopped.") + return + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + self.status.set("OCP stopped.") + + def close(self) -> None: + self.closing = True + self.stop() + self.root.destroy() + + def restart(self) -> None: + mode = self.current_plan.mode if self.current_plan else MESH_MODE + self.stop() + self._start(mode) + + def open_app(self) -> None: + plan = self.current_plan or build_launch_plan(LOCAL_MODE, self._current_config(), self.repo_root) + webbrowser.open(self._app_link(plan, plan.app_url)) + + def copy_phone_link(self) -> None: + plan = self.current_plan or build_launch_plan(MESH_MODE, self._current_config(), self.repo_root) + link = self._app_link(plan, (plan.share_urls or [plan.app_url])[0]) + self.root.clipboard_clear() + self.root.clipboard_append(link) + self.status.set(f"Copied phone link: {link}") + + def _app_link(self, plan: LaunchPlan, base_url: str) -> str: + token = self._operator_token_for_mode(plan.mode) + return operator_app_url(base_url, token) + + def _render_links(self, plan: LaunchPlan) -> None: + rows = [f"App: {self._app_link(plan, plan.app_url)}"] + if plan.share_urls: + rows.append("Phone/LAN:") + rows.extend(f" {self._app_link(plan, url)}" for url in plan.share_urls) + else: + rows.append("Phone/LAN: start Mesh Mode on Wi-Fi to expose a LAN link.") + self.urls.set("\n".join(rows)) + + def _poll_status(self) -> None: + if self.closing: + return + plan = self.current_plan + if plan and self.process and self.process.poll() is None: + if server_is_alive(plan): + self.status.set(f"OCP is running. Open {plan.app_url} or use the phone link below.") + else: + self.status.set("OCP is starting...") + elif self.process and self.process.poll() is not None: + self.status.set(f"OCP stopped with exit code {self.process.returncode}.") + self.root.after(1500, self._poll_status) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Launch the OCP desktop app.") + parser.add_argument("--repo-root", default=str(Path(__file__).resolve().parents[1])) + parser.add_argument("--config-path", default="") + parser.add_argument("--plan", choices=[LOCAL_MODE, MESH_MODE], default="", help="Print a launch plan and exit.") + args = parser.parse_args(argv) + repo_root = Path(args.repo_root).resolve() + config_path = Path(args.config_path).expanduser() if args.config_path else launcher_config_path() + if args.plan: + plan = build_launch_plan(args.plan, load_launcher_config(config_path), repo_root, config_path=config_path) + print(plan.as_dict()) + return 0 + + try: + import tkinter as tk + except Exception as exc: + print(f"tkinter is required for the OCP desktop launcher: {exc}", file=sys.stderr) + return 2 + + root = tk.Tk() + OCPLauncherApp(root, repo_root=repo_root, config_path=config_path) + root.mainloop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ocp_desktop/macos_app.py b/ocp_desktop/macos_app.py new file mode 100644 index 0000000..17abfc5 --- /dev/null +++ b/ocp_desktop/macos_app.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import argparse +import plistlib +import shutil +import stat +from pathlib import Path + +DEFAULT_APP_NAME = "OCP" +DEFAULT_BUNDLE_ID = "org.opencomputeprotocol.ocp" + +EXCLUDED_DIR_NAMES = { + ".git", + ".local", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "__pycache__", + "build", + "dist", + "env", + "htmlcov", + "identities", + "identity", + "logs", + "node_modules", + "tests", + "tmp", + "venv", + ".venv", + ".vscode", + ".idea", +} +EXCLUDED_FILE_NAMES = { + ".coverage", + ".DS_Store", + ".env", + "launcher.json", + "ocp.db", +} +EXCLUDED_FILE_PREFIXES = ( + ".env.", +) +EXCLUDED_FILE_SUFFIXES = { + ".db", + ".db-shm", + ".db-wal", + ".der", + ".crt", + ".err", + ".key", + ".log", + ".out", + ".p12", + ".pem", + ".pid", + ".sqlite", + ".sqlite3", + ".sqlite3-shm", + ".sqlite3-wal", + ".tmp", + ".pyc", + ".pyo", +} + + +def should_exclude(path: Path, repo_root: Path) -> bool: + try: + relative = Path(path).resolve().relative_to(Path(repo_root).resolve()) + except ValueError: + relative = Path(path) + parts = relative.parts + if any(part in EXCLUDED_DIR_NAMES or part.startswith(".mesh") for part in parts): + return True + name = Path(path).name + if name in EXCLUDED_FILE_NAMES: + return True + if any(name.startswith(prefix) for prefix in EXCLUDED_FILE_PREFIXES): + return True + if name.startswith(".mesh"): + return True + if Path(path).suffix.lower() in EXCLUDED_FILE_SUFFIXES: + return True + return False + + +def _copy_repo(repo_root: Path, destination: Path) -> None: + for source in Path(repo_root).iterdir(): + if should_exclude(source, repo_root): + continue + target = destination / source.name + if source.is_dir(): + shutil.copytree( + source, + target, + ignore=lambda directory, names: [ + name for name in names if should_exclude(Path(directory) / name, repo_root) + ], + ) + else: + shutil.copy2(source, target) + + +def _write_info_plist(contents_dir: Path, *, app_name: str, bundle_id: str) -> None: + plist = { + "CFBundleDevelopmentRegion": "en", + "CFBundleDisplayName": app_name, + "CFBundleExecutable": app_name, + "CFBundleIdentifier": bundle_id, + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": app_name, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "0.1.0", + "CFBundleVersion": "1", + "LSMinimumSystemVersion": "12.0", + "NSHighResolutionCapable": True, + } + with (contents_dir / "Info.plist").open("wb") as handle: + plistlib.dump(plist, handle) + + +def _write_launcher_executable(macos_dir: Path, *, app_name: str) -> Path: + executable = macos_dir / app_name + executable.write_text( + """#!/bin/sh +APP_CONTENTS="$(cd "$(dirname "$0")/.." && pwd)" +REPO_ROOT="$APP_CONTENTS/Resources/open-compute-protocol" +cd "$REPO_ROOT" || exit 1 +exec /usr/bin/env python3 -m ocp_desktop.launcher --repo-root "$REPO_ROOT" "$@" +""", + encoding="utf-8", + ) + current = executable.stat().st_mode + executable.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return executable + + +def build_macos_app( + repo_root: Path, + *, + dist_dir: Path | None = None, + app_name: str = DEFAULT_APP_NAME, + bundle_id: str = DEFAULT_BUNDLE_ID, +) -> dict[str, str]: + root = Path(repo_root).resolve() + output_dir = Path(dist_dir).resolve() if dist_dir else root / "dist" + app_dir = output_dir / f"{app_name}.app" + contents_dir = app_dir / "Contents" + macos_dir = contents_dir / "MacOS" + resources_dir = contents_dir / "Resources" + bundled_repo = resources_dir / "open-compute-protocol" + + if app_dir.exists(): + shutil.rmtree(app_dir) + macos_dir.mkdir(parents=True, exist_ok=True) + bundled_repo.mkdir(parents=True, exist_ok=True) + + _write_info_plist(contents_dir, app_name=app_name, bundle_id=bundle_id) + executable = _write_launcher_executable(macos_dir, app_name=app_name) + _copy_repo(root, bundled_repo) + + return { + "status": "ok", + "app_path": str(app_dir), + "executable": str(executable), + "bundled_repo": str(bundled_repo), + "note": "Unsigned beta bundle. Requires python3 to be installed on the Mac.", + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Build an unsigned macOS OCP.app beta bundle.") + parser.add_argument("--repo-root", default=str(Path(__file__).resolve().parents[1])) + parser.add_argument("--dist-dir", default="") + parser.add_argument("--app-name", default=DEFAULT_APP_NAME) + parser.add_argument("--bundle-id", default=DEFAULT_BUNDLE_ID) + args = parser.parse_args(argv) + result = build_macos_app( + Path(args.repo_root), + dist_dir=Path(args.dist_dir) if args.dist_dir else None, + app_name=args.app_name, + bundle_id=args.bundle_id, + ) + print(f"Built {result['app_path']}") + print(result["note"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ocp_startup.py b/ocp_startup.py new file mode 100644 index 0000000..c7104e2 --- /dev/null +++ b/ocp_startup.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import ipaddress +import json +import os +import socket +import sys +import time +import urllib.error +import urllib.request +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + + +@dataclass(frozen=True) +class StartupPaths: + state_dir: Path + db_path: Path + identity_dir: Path + workspace_root: Path + + def as_strings(self) -> dict[str, str]: + return {key: str(value) for key, value in asdict(self).items()} + + +@dataclass(frozen=True) +class StartupProfile: + host: str + port: int + node_id: str + display_name: str + device_class: str + form_factor: str + db_path: Path + identity_dir: Path + workspace_root: Path + base_url: str = "" + + +def slugify(value: str) -> str: + chars = [] + last_dash = False + for ch in (value or "").lower(): + if ch.isalnum(): + chars.append(ch) + last_dash = False + continue + if not last_dash: + chars.append("-") + last_dash = True + return "".join(chars).strip("-") + + +def default_node_id(host_name: str | None = None) -> str: + name = host_name + if name is None: + name = os.uname().nodename if hasattr(os, "uname") else os.environ.get("COMPUTERNAME", "ocp") + token = slugify(str(name or "")) or "ocp" + return f"{token}-node" + + +def display_host_for_browser(host: str) -> str: + if host in {"0.0.0.0", "::", ""}: + return "127.0.0.1" + return host + + +def is_wildcard_host(host: str) -> bool: + return str(host or "").strip().lower() in {"", "0.0.0.0", "::", "[::]"} + + +def is_loopback_host(host: str) -> bool: + token = str(host or "").strip().lower() + return token == "localhost" or token.startswith("127.") + + +def discover_local_ipv4_addresses(*, bind_host: str = "") -> list[str]: + seen: set[str] = set() + bind_token = str(bind_host or "").strip() + if bind_token and not is_wildcard_host(bind_token) and not is_loopback_host(bind_token): + try: + if ipaddress.ip_address(bind_token).version == 4: + seen.add(bind_token) + except ValueError: + pass + try: + addrinfo_rows = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET, socket.SOCK_DGRAM) + except OSError: + addrinfo_rows = [] + for family, _, _, _, sockaddr in addrinfo_rows: + if family != socket.AF_INET or not sockaddr: + continue + host = str(sockaddr[0] or "").strip() + if host and not is_wildcard_host(host) and not is_loopback_host(host): + seen.add(host) + for probe_host in ("192.0.2.1", "10.255.255.255"): + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect((probe_host, 80)) + host = str(sock.getsockname()[0] or "").strip() + if host and not is_wildcard_host(host) and not is_loopback_host(host): + seen.add(host) + except OSError: + continue + return sorted( + (host for host in seen if host and ipaddress.ip_address(host).version == 4), + key=lambda host: (not ipaddress.ip_address(host).is_private, host), + ) + + +def share_urls_for_host( + host: str, + port: int, + *, + discover_ipv4: Callable[..., list[str]] | None = None, +) -> list[str]: + token = str(host or "").strip() + if token and not is_wildcard_host(token) and not is_loopback_host(token): + return [f"http://{token}:{int(port)}/"] + if is_wildcard_host(token): + discover = discover_ipv4 or discover_local_ipv4_addresses + return [f"http://{address}:{int(port)}/" for address in discover(bind_host=token)] + return [] + + +def build_open_url(host: str, port: int, path: str = "/") -> str: + route = path if str(path or "").startswith("/") else f"/{path}" + route = route or "/" + return f"http://{display_host_for_browser(host)}:{int(port)}{route}" + + +def health_url(host: str, port: int) -> str: + return build_open_url(host, port, "/mesh/manifest") + + +def default_repo_state_dir(repo_root: Path) -> Path: + return Path(repo_root) / ".local" / "ocp" + + +def default_launcher_support_dir(*, home: Path | None = None) -> Path: + base = Path(home) if home is not None else Path.home() + return base / "Library" / "Application Support" / "OCP" + + +def default_launcher_config_path(*, home: Path | None = None) -> Path: + return default_launcher_support_dir(home=home) / "launcher.json" + + +def default_launcher_state_dir(*, home: Path | None = None) -> Path: + return default_launcher_support_dir(home=home) / "state" + + +def resolve_state_paths( + repo_root: Path, + *, + db_path: str | Path = "", + identity_dir: str | Path = "", + workspace_root: str | Path = "", + state_dir: str | Path | None = None, + create: bool = True, +) -> StartupPaths: + root = Path(repo_root).resolve() + explicit_db = Path(db_path).expanduser() if db_path else None + if state_dir: + resolved_state = Path(state_dir).expanduser() + elif explicit_db is not None: + resolved_state = explicit_db.parent + else: + resolved_state = default_repo_state_dir(root) + db = explicit_db if explicit_db is not None else resolved_state / "ocp.db" + identity = Path(identity_dir).expanduser() if identity_dir else resolved_state / "identity" + workspace = Path(workspace_root).expanduser() if workspace_root else resolved_state / "workspace" + paths = StartupPaths( + state_dir=resolved_state, + db_path=db, + identity_dir=identity, + workspace_root=workspace, + ) + if create: + ensure_state_paths(paths) + return paths + + +def ensure_state_paths(paths: StartupPaths | StartupProfile) -> None: + db_path = getattr(paths, "db_path") + identity_dir = getattr(paths, "identity_dir") + workspace_root = getattr(paths, "workspace_root") + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + Path(identity_dir).mkdir(parents=True, exist_ok=True) + Path(workspace_root).mkdir(parents=True, exist_ok=True) + state_dir = getattr(paths, "state_dir", None) + if state_dir is not None: + Path(state_dir).mkdir(parents=True, exist_ok=True) + + +def profile_from_values( + repo_root: Path, + *, + host: str = "127.0.0.1", + port: int = 8421, + node_id: str = "", + display_name: str = "OCP Node", + device_class: str = "full", + form_factor: str = "workstation", + db_path: str | Path = "", + identity_dir: str | Path = "", + workspace_root: str | Path = "", + state_dir: str | Path | None = None, + base_url: str = "", + create_paths: bool = True, +) -> StartupProfile: + paths = resolve_state_paths( + repo_root, + db_path=db_path, + identity_dir=identity_dir, + workspace_root=workspace_root, + state_dir=state_dir, + create=create_paths, + ) + return StartupProfile( + host=str(host or "127.0.0.1"), + port=int(port), + node_id=str(node_id or default_node_id()).strip() or default_node_id(), + display_name=str(display_name or "OCP Node").strip() or "OCP Node", + device_class=str(device_class or "full").strip() or "full", + form_factor=str(form_factor or "workstation").strip() or "workstation", + db_path=paths.db_path, + identity_dir=paths.identity_dir, + workspace_root=paths.workspace_root, + base_url=str(base_url or "").strip(), + ) + + +def server_command( + profile: StartupProfile, + repo_root: Path, + *, + python_executable: str | Path | None = None, +) -> list[str]: + command = [ + str(python_executable or sys.executable), + str(Path(repo_root) / "server.py"), + "--host", + profile.host, + "--port", + str(int(profile.port)), + "--db-path", + str(profile.db_path), + "--workspace-root", + str(profile.workspace_root), + "--identity-dir", + str(profile.identity_dir), + "--node-id", + profile.node_id, + "--display-name", + profile.display_name, + "--device-class", + profile.device_class, + "--form-factor", + profile.form_factor, + ] + if profile.base_url: + command.extend(["--base-url", profile.base_url]) + return command + + +def wait_for_manifest(host: str, port: int, timeout_seconds: float) -> bool: + url = health_url(host, port) + deadline = time.time() + max(timeout_seconds, 1.0) + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1.0) as response: + if response.status == 200: + return True + except (urllib.error.URLError, OSError): + time.sleep(0.35) + return False + + +def read_json_file(path: Path, *, default: dict | None = None) -> dict: + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return dict(default or {}) + + +def write_json_file(path: Path, payload: dict) -> None: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(dict(payload or {}), indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +__all__ = [ + "StartupPaths", + "StartupProfile", + "build_open_url", + "default_launcher_config_path", + "default_launcher_state_dir", + "default_launcher_support_dir", + "default_node_id", + "default_repo_state_dir", + "discover_local_ipv4_addresses", + "display_host_for_browser", + "ensure_state_paths", + "health_url", + "is_loopback_host", + "is_wildcard_host", + "profile_from_values", + "read_json_file", + "resolve_state_paths", + "server_command", + "share_urls_for_host", + "slugify", + "wait_for_manifest", + "write_json_file", +] diff --git a/scripts/build_macos_app.py b/scripts/build_macos_app.py new file mode 100755 index 0000000..022a170 --- /dev/null +++ b/scripts/build_macos_app.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Build the unsigned macOS OCP.app beta bundle.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from ocp_desktop.macos_app import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/start_ocp_easy.py b/scripts/start_ocp_easy.py old mode 100644 new mode 100755 index bcab61e..89b7557 --- a/scripts/start_ocp_easy.py +++ b/scripts/start_ocp_easy.py @@ -4,103 +4,49 @@ from __future__ import annotations import argparse -import ipaddress import os -import socket import subprocess import sys -import time -import urllib.error -import urllib.request import webbrowser from pathlib import Path +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import ocp_startup + def slugify(value: str) -> str: - chars = [] - last_dash = False - for ch in (value or "").lower(): - if ch.isalnum(): - chars.append(ch) - last_dash = False - continue - if not last_dash: - chars.append("-") - last_dash = True - return "".join(chars).strip("-") + return ocp_startup.slugify(value) def default_node_id() -> str: - host_name = os.uname().nodename if hasattr(os, "uname") else os.environ.get("COMPUTERNAME", "ocp") - token = slugify(host_name) or "ocp" - return f"{token}-node" + return ocp_startup.default_node_id() def display_host_for_browser(host: str) -> str: - if host in {"0.0.0.0", "::", ""}: - return "127.0.0.1" - return host + return ocp_startup.display_host_for_browser(host) def is_wildcard_host(host: str) -> bool: - return str(host or "").strip().lower() in {"", "0.0.0.0", "::", "[::]"} + return ocp_startup.is_wildcard_host(host) def is_loopback_host(host: str) -> bool: - token = str(host or "").strip().lower() - return token == "localhost" or token.startswith("127.") + return ocp_startup.is_loopback_host(host) def discover_local_ipv4_addresses(*, bind_host: str = "") -> list[str]: - seen: set[str] = set() - bind_token = str(bind_host or "").strip() - if bind_token and not is_wildcard_host(bind_token) and not is_loopback_host(bind_token): - try: - if ipaddress.ip_address(bind_token).version == 4: - seen.add(bind_token) - except ValueError: - pass - try: - addrinfo_rows = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET, socket.SOCK_DGRAM) - except OSError: - addrinfo_rows = [] - for family, _, _, _, sockaddr in addrinfo_rows: - if family != socket.AF_INET or not sockaddr: - continue - host = str(sockaddr[0] or "").strip() - if host and not is_wildcard_host(host) and not is_loopback_host(host): - seen.add(host) - for probe_host in ("192.0.2.1", "10.255.255.255"): - try: - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: - sock.connect((probe_host, 80)) - host = str(sock.getsockname()[0] or "").strip() - if host and not is_wildcard_host(host) and not is_loopback_host(host): - seen.add(host) - except OSError: - continue - return sorted( - ( - host - for host in seen - if host and ipaddress.ip_address(host).version == 4 - ), - key=lambda host: (not ipaddress.ip_address(host).is_private, host), - ) + return ocp_startup.discover_local_ipv4_addresses(bind_host=bind_host) def share_urls_for_host(host: str, port: int) -> list[str]: - token = str(host or "").strip() - if token and not is_wildcard_host(token) and not is_loopback_host(token): - return [f"http://{token}:{int(port)}/"] - if is_wildcard_host(token): - return [f"http://{address}:{int(port)}/" for address in discover_local_ipv4_addresses(bind_host=token)] - return [] + return ocp_startup.share_urls_for_host(host, port, discover_ipv4=discover_local_ipv4_addresses) def build_open_url(host: str, port: int, path: str = "/") -> str: - route = path if str(path or "").startswith("/") else f"/{path}" - return f"http://{display_host_for_browser(host)}:{int(port)}{route}" + return ocp_startup.build_open_url(host, port, path) def build_parser() -> argparse.ArgumentParser: @@ -122,59 +68,37 @@ def build_parser() -> argparse.ArgumentParser: def wait_for_manifest(host: str, port: int, timeout_seconds: float) -> bool: - url = build_open_url(host, port, "/mesh/manifest") - deadline = time.time() + max(timeout_seconds, 1.0) - while time.time() < deadline: - try: - with urllib.request.urlopen(url, timeout=1.0) as response: - if response.status == 200: - return True - except (urllib.error.URLError, OSError): - time.sleep(0.35) - return False + return ocp_startup.wait_for_manifest(host, port, timeout_seconds) + + +def _profile_from_args(args: argparse.Namespace, repo_root: Path) -> ocp_startup.StartupProfile: + return ocp_startup.profile_from_values( + repo_root, + host=args.host, + port=args.port, + db_path=args.db_path, + workspace_root=args.workspace_root, + identity_dir=args.identity_dir, + node_id=args.node_id, + display_name=args.display_name, + device_class=args.device_class, + form_factor=args.form_factor, + base_url=args.base_url, + create_paths=True, + ) def server_command(args: argparse.Namespace, repo_root: Path) -> list[str]: - state_dir = Path(args.db_path).parent if args.db_path else (repo_root / ".local" / "ocp") - db_path = Path(args.db_path) if args.db_path else (state_dir / "ocp.db") - identity_dir = Path(args.identity_dir) if args.identity_dir else (state_dir / "identity") - workspace_root = Path(args.workspace_root) if args.workspace_root else (state_dir / "workspace") - identity_dir.mkdir(parents=True, exist_ok=True) - workspace_root.mkdir(parents=True, exist_ok=True) - db_path.parent.mkdir(parents=True, exist_ok=True) - command = [ - sys.executable, - str(repo_root / "server.py"), - "--host", - args.host, - "--port", - str(args.port), - "--db-path", - str(db_path), - "--workspace-root", - str(workspace_root), - "--identity-dir", - str(identity_dir), - "--node-id", - args.node_id, - "--display-name", - args.display_name, - "--device-class", - args.device_class, - "--form-factor", - args.form_factor, - ] - if args.base_url: - command.extend(["--base-url", args.base_url]) - return command + return ocp_startup.server_command(_profile_from_args(args, repo_root), repo_root) def main() -> int: parser = build_parser() args = parser.parse_args() - repo_root = Path(__file__).resolve().parents[1] + repo_root = REPO_ROOT command = server_command(args, repo_root) open_url = build_open_url(args.host, args.port, args.open_path) + profile = _profile_from_args(args, repo_root) print("Starting The Open Compute Protocol") print() @@ -183,6 +107,9 @@ def main() -> int: print(f" port: {args.port}") print(f" node id: {args.node_id}") print(f" display name: {args.display_name}") + print(f" db: {profile.db_path}") + print(f" identity: {profile.identity_dir}") + print(f" workspace: {profile.workspace_root}") print() print("OCP app:") print(f" {open_url}") diff --git a/server.py b/server.py index 4e3c1ef..26fc799 100644 --- a/server.py +++ b/server.py @@ -15,6 +15,7 @@ from mesh.sovereign import _normalize_base_url, _preferred_local_base_url from runtime import OCPRegistry, OCPStore from server_app import build_app_manifest as _build_app_manifest, build_app_page as _build_app_page +from server_app_status import build_app_status as _build_app_status from server_connect import build_easy_page as _build_easy_page from server_control import ( build_control_state as _build_control_state, @@ -78,6 +79,10 @@ def build_app_manifest(mesh: SovereignMesh) -> dict[str, Any]: return _build_app_manifest(mesh) +def build_app_status(mesh: SovereignMesh) -> dict[str, Any]: + return _build_app_status(mesh) + + class OCPHandler(OCPRouteHandlerMixin, BaseHTTPRequestHandler): def log_message(self, fmt, *args): return diff --git a/server_app.py b/server_app.py index 4c72cec..aeeeb39 100644 --- a/server_app.py +++ b/server_app.py @@ -58,6 +58,7 @@ def build_app_page(mesh: SovereignMesh) -> str: + OCP App