openadapt-desktop 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
engine/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """OpenAdapt Desktop Engine -- Python sidecar for the Tauri shell.
2
+
3
+ This package implements the recording engine, PII scrubbing, storage management,
4
+ upload backends, and health monitoring. It runs as a standalone process that
5
+ communicates with the Tauri shell via JSON-over-stdin/stdout IPC.
6
+
7
+ Architecture:
8
+ Tauri Shell (Rust + WebView)
9
+ | IPC (JSON over stdin/stdout)
10
+ v
11
+ Python Engine (this package)
12
+ +-- controller.py Recording start/stop/pause
13
+ +-- ipc.py JSON line protocol handler
14
+ +-- storage_manager.py Storage tiers, cleanup, index
15
+ +-- upload_manager.py Multi-backend upload with queue
16
+ +-- scrubber.py PII scrubbing orchestration
17
+ +-- review.py Upload review state machine
18
+ +-- config.py Settings (pydantic-settings)
19
+ +-- monitor.py Health monitoring (memory, disk, watchdog)
20
+ +-- audit.py Network audit logging
21
+ +-- backends/ Storage backend plugins
22
+ """
23
+
24
+ __version__ = "0.2.0"
engine/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m engine`."""
2
+
3
+ from engine.main import main
4
+
5
+ main()
engine/audit.py ADDED
@@ -0,0 +1,125 @@
1
+ """Network audit logging -- append-only JSONL log of all outbound network activity.
2
+
3
+ Every outbound network request is logged to an append-only JSONL file at
4
+ ~/.openadapt/audit.jsonl. This enables enterprise IT to:
5
+ - grep the audit log for unexpected destinations
6
+ - set firewall rules allowing only their S3 endpoint
7
+ - use a network proxy to independently verify traffic
8
+ - run OPENADAPT_STORAGE_MODE=air-gapped and confirm zero outbound traffic
9
+
10
+ Log format (from design doc Section 7.9):
11
+ {"ts":"2026-03-02T10:00:01Z","event":"startup","storage_mode":"enterprise","backends":["s3"],"excluded":["hf","r2","ipfs"]}
12
+ {"ts":"2026-03-02T10:05:00Z","event":"upload_start","backend":"s3","dest":"s3://bucket/rec.tar.zst","size_mb":142}
13
+ {"ts":"2026-03-02T10:05:32Z","event":"upload_complete","backend":"s3","dest":"s3://bucket/rec.tar.zst","bytes_sent":148897280}
14
+
15
+ All significant actions are logged:
16
+ - Recording started/stopped
17
+ - Upload initiated/completed/failed (with destination URL)
18
+ - Settings changed
19
+ - Data deleted (local or cloud)
20
+ - Update installed
21
+ - Permissions granted/denied
22
+ - Every outbound network request (destination, size, response code)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+
33
+ class AuditLogger:
34
+ """Append-only audit logger for network and significant events.
35
+
36
+ Args:
37
+ log_path: Path to the audit.jsonl file.
38
+ enabled: Whether audit logging is active.
39
+ """
40
+
41
+ def __init__(self, log_path: Path, enabled: bool = True) -> None:
42
+ self.log_path = log_path
43
+ self.enabled = enabled
44
+
45
+ def log(self, event: str, **data: Any) -> None:
46
+ """Write an audit log entry.
47
+
48
+ Args:
49
+ event: Event type (e.g., "startup", "upload_start", "settings_changed").
50
+ **data: Additional event data as keyword arguments.
51
+ """
52
+ if not self.enabled:
53
+ return
54
+
55
+ entry = {
56
+ "ts": datetime.now(timezone.utc).isoformat(),
57
+ "event": event,
58
+ **data,
59
+ }
60
+
61
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
62
+ line = json.dumps(entry)
63
+ with open(self.log_path, "a") as f:
64
+ f.write(line + "\n")
65
+
66
+ def log_startup(self, storage_mode: str, active_backends: list[str]) -> None:
67
+ """Log engine startup with active configuration.
68
+
69
+ Args:
70
+ storage_mode: Current storage mode (air-gapped, enterprise, community, full).
71
+ active_backends: List of active backend names.
72
+ """
73
+ all_backends = {"s3", "r2", "hf", "wormhole", "federated"}
74
+ excluded = sorted(all_backends - set(active_backends))
75
+ self.log(
76
+ "startup",
77
+ storage_mode=storage_mode,
78
+ backends=active_backends,
79
+ excluded=excluded,
80
+ )
81
+
82
+ def log_upload_start(self, backend: str, dest: str, size_bytes: int) -> None:
83
+ """Log the start of an upload operation.
84
+
85
+ Args:
86
+ backend: Storage backend name.
87
+ dest: Destination URI (e.g., "s3://bucket/key").
88
+ size_bytes: Size of the data being uploaded.
89
+ """
90
+ self.log(
91
+ "upload_start",
92
+ backend=backend,
93
+ dest=dest,
94
+ size_mb=round(size_bytes / (1024 * 1024), 1),
95
+ )
96
+
97
+ def log_upload_complete(self, backend: str, dest: str, bytes_sent: int) -> None:
98
+ """Log the completion of an upload operation.
99
+
100
+ Args:
101
+ backend: Storage backend name.
102
+ dest: Destination URI.
103
+ bytes_sent: Total bytes sent.
104
+ """
105
+ self.log(
106
+ "upload_complete",
107
+ backend=backend,
108
+ dest=dest,
109
+ bytes_sent=bytes_sent,
110
+ )
111
+
112
+ def log_upload_failed(self, backend: str, dest: str, error: str) -> None:
113
+ """Log a failed upload operation.
114
+
115
+ Args:
116
+ backend: Storage backend name.
117
+ dest: Destination URI.
118
+ error: Error message.
119
+ """
120
+ self.log(
121
+ "upload_failed",
122
+ backend=backend,
123
+ dest=dest,
124
+ error=error,
125
+ )
@@ -0,0 +1,12 @@
1
+ """Storage backend plugins for OpenAdapt Desktop.
2
+
3
+ Each backend implements the StorageBackend protocol defined in protocol.py.
4
+ Backends are loaded conditionally based on build-time feature flags and
5
+ runtime configuration.
6
+
7
+ Available backends:
8
+ s3 S3-compatible (AWS S3, Cloudflare R2, MinIO)
9
+ huggingface HuggingFace Hub (public/private datasets)
10
+ wormhole Magic Wormhole (P2P ephemeral transfer)
11
+ federated Federated learning gradient upload (Flower)
12
+ """
@@ -0,0 +1,117 @@
1
+ """Federated learning gradient upload backend -- Flower-based federated learning.
2
+
3
+ Federated learning solves the tension between privacy and model improvement:
4
+ - Enterprise users get model improvements WITHOUT sharing any data
5
+ - Community users get model improvements WITHOUT seeing enterprise screens
6
+ - OpenAdapt trains better models without centralized data collection
7
+
8
+ What gets shared:
9
+ - Model weight deltas (compressed, ~1-10 MB per round)
10
+ - Participation metadata (sample count, training loss)
11
+ - Differentially private gradients (optionally)
12
+
13
+ What does NOT get shared:
14
+ - Raw screenshots
15
+ - Keyboard/mouse events
16
+ - Any recording content
17
+
18
+ Phasing (from design doc Section 9.8):
19
+ v0.1-v0.5: No federated (data collection focus)
20
+ v1.0: Custom gradient API (manual model update sharing)
21
+ v2.0: Flower integration (automated federated rounds)
22
+ v3.0: Secure aggregation + DP (enterprise-grade privacy)
23
+
24
+ Requires: flwr, torch (installed via federated or full extras)
25
+
26
+ See design doc Section 9 for the full federated learning design.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from pathlib import Path
32
+
33
+ from engine.backends.protocol import UploadRecord, UploadResult
34
+
35
+
36
+ class FederatedBackend:
37
+ """Federated learning gradient upload backend.
38
+
39
+ Uploads model gradients (NOT raw data) to a Flower aggregation server.
40
+ The server averages gradients from all participants to produce an
41
+ improved global model.
42
+
43
+ Args:
44
+ server_url: Flower aggregation server URL.
45
+ rounds_per_day: How many federated rounds to participate in per day.
46
+ min_local_samples: Minimum number of local recordings before participating.
47
+ differential_privacy: Whether to add DP noise to gradients.
48
+ epsilon: Privacy budget (lower = more private, noisier gradients).
49
+ """
50
+
51
+ name: str = "federated"
52
+ supports_delete: bool = False
53
+ supports_list: bool = False
54
+
55
+ def __init__(
56
+ self,
57
+ server_url: str = "https://fl.openadapt.ai",
58
+ rounds_per_day: int = 1,
59
+ min_local_samples: int = 100,
60
+ differential_privacy: bool = True,
61
+ epsilon: float = 1.0,
62
+ ) -> None:
63
+ self.server_url = server_url
64
+ self.rounds_per_day = rounds_per_day
65
+ self.min_local_samples = min_local_samples
66
+ self.differential_privacy = differential_privacy
67
+ self.epsilon = epsilon
68
+
69
+ def upload(self, archive_path: Path, metadata: dict) -> UploadResult:
70
+ """Upload model gradients (not raw data) to the FL server.
71
+
72
+ This method performs local training on the capture data and
73
+ uploads only the resulting model weight deltas.
74
+
75
+ Args:
76
+ archive_path: Path to the capture (used for local training, not uploaded).
77
+ metadata: Capture metadata.
78
+
79
+ Returns:
80
+ UploadResult with gradient upload confirmation.
81
+ """
82
+ # TODO: Load base model
83
+ # TODO: Fine-tune on local capture data
84
+ # TODO: Compute gradient deltas
85
+ # TODO: Apply differential privacy noise if enabled
86
+ # TODO: Upload gradients to Flower server
87
+ raise NotImplementedError
88
+
89
+ def delete(self, recording_id: str) -> bool:
90
+ """Not supported -- gradients cannot be individually deleted from the aggregate."""
91
+ raise NotImplementedError(
92
+ "Federated gradients are aggregated and cannot be individually deleted"
93
+ )
94
+
95
+ def list_uploads(self) -> list[UploadRecord]:
96
+ """Not supported for federated learning."""
97
+ raise NotImplementedError("Federated gradient uploads cannot be listed")
98
+
99
+ def verify_credentials(self) -> bool:
100
+ """Verify connection to the Flower aggregation server.
101
+
102
+ Returns:
103
+ True if the server is reachable.
104
+ """
105
+ # TODO: HTTP health check to server_url
106
+ raise NotImplementedError
107
+
108
+ def estimate_cost(self, size_bytes: int) -> float | None:
109
+ """Federated learning has no per-upload cost for participants.
110
+
111
+ Args:
112
+ size_bytes: Ignored (gradients are ~1-10 MB regardless of data size).
113
+
114
+ Returns:
115
+ Always 0.0 for participants.
116
+ """
117
+ return 0.0
@@ -0,0 +1,160 @@
1
+ """HuggingFace Hub storage backend -- uploads recordings as dataset shards.
2
+
3
+ Uploads capture archives to a HuggingFace dataset repository using the
4
+ huggingface_hub Python library. Recordings are stored as dataset shards
5
+ in a community dataset repo (e.g., OpenAdaptAI/desktop-recordings).
6
+
7
+ Features:
8
+ - Versioned via Git LFS
9
+ - Built-in dataset viewer lets community browse without downloading
10
+ - Supports both public and private (paid HF tier) datasets
11
+
12
+ Requires: huggingface_hub (installed via community or full extras)
13
+
14
+ See design doc Section 7.7 for implementation details.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ from engine.backends.protocol import UploadRecord, UploadResult
22
+
23
+
24
+ class HuggingFaceBackend:
25
+ """HuggingFace Hub storage backend.
26
+
27
+ Args:
28
+ repo: HuggingFace dataset repository (e.g., "OpenAdaptAI/desktop-recordings").
29
+ token: HuggingFace API token.
30
+ private: Whether to create a private dataset (requires paid HF tier).
31
+ """
32
+
33
+ name: str = "huggingface"
34
+ supports_delete: bool = True
35
+ supports_list: bool = True
36
+
37
+ def __init__(
38
+ self,
39
+ repo: str = "OpenAdaptAI/desktop-recordings",
40
+ token: str = "",
41
+ private: bool = False,
42
+ ) -> None:
43
+ self.repo = repo
44
+ self.token = token
45
+ self.private = private
46
+
47
+ def upload(self, archive_path: Path, metadata: dict) -> UploadResult:
48
+ """Upload a capture archive to HuggingFace Hub.
49
+
50
+ Args:
51
+ archive_path: Path to the archive file.
52
+ metadata: Capture metadata.
53
+
54
+ Returns:
55
+ UploadResult with the HF Hub URL.
56
+ """
57
+ try:
58
+ from huggingface_hub import HfApi
59
+
60
+ api = HfApi(token=self.token)
61
+ capture_id = metadata.get("capture_id", "unknown")
62
+ path_in_repo = f"captures/{capture_id}/{archive_path.name}"
63
+
64
+ api.upload_file(
65
+ path_or_fileobj=str(archive_path),
66
+ path_in_repo=path_in_repo,
67
+ repo_id=self.repo,
68
+ repo_type="dataset",
69
+ )
70
+
71
+ url = f"https://huggingface.co/datasets/{self.repo}/blob/main/{path_in_repo}"
72
+ return UploadResult(
73
+ success=True,
74
+ remote_url=url,
75
+ bytes_sent=archive_path.stat().st_size,
76
+ )
77
+ except ImportError:
78
+ return UploadResult(success=False, error="huggingface_hub not installed")
79
+ except Exception as e:
80
+ return UploadResult(success=False, error=str(e))
81
+
82
+ def delete(self, recording_id: str) -> bool:
83
+ """Delete a recording from HuggingFace Hub.
84
+
85
+ Args:
86
+ recording_id: The capture session ID.
87
+
88
+ Returns:
89
+ True if deletion succeeded.
90
+ """
91
+ try:
92
+ from huggingface_hub import HfApi
93
+
94
+ api = HfApi(token=self.token)
95
+ api.delete_folder(
96
+ path_in_repo=f"captures/{recording_id}",
97
+ repo_id=self.repo,
98
+ repo_type="dataset",
99
+ )
100
+ return True
101
+ except Exception:
102
+ return False
103
+
104
+ def list_uploads(self) -> list[UploadRecord]:
105
+ """List all uploads in the HuggingFace dataset repo.
106
+
107
+ Returns:
108
+ List of UploadRecord objects.
109
+ """
110
+ try:
111
+ from huggingface_hub import HfApi
112
+
113
+ api = HfApi(token=self.token)
114
+ files = api.list_repo_files(repo_id=self.repo, repo_type="dataset")
115
+ records = []
116
+ for f in files:
117
+ if f.startswith("captures/"):
118
+ parts = f.split("/")
119
+ recording_id = parts[1] if len(parts) > 1 else "unknown"
120
+ records.append(UploadRecord(
121
+ recording_id=recording_id,
122
+ backend="huggingface",
123
+ remote_url=f"https://huggingface.co/datasets/{self.repo}/blob/main/{f}",
124
+ uploaded_at="",
125
+ size_bytes=0,
126
+ ))
127
+ return records
128
+ except Exception:
129
+ return []
130
+
131
+ def verify_credentials(self) -> bool:
132
+ """Verify HuggingFace token by calling the whoami endpoint.
133
+
134
+ Returns:
135
+ True if the token is valid.
136
+ """
137
+ try:
138
+ from huggingface_hub import HfApi
139
+
140
+ api = HfApi(token=self.token)
141
+ api.whoami()
142
+ return True
143
+ except Exception:
144
+ return False
145
+
146
+ def estimate_cost(self, size_bytes: int) -> float | None:
147
+ """Estimate cost for HuggingFace Hub storage.
148
+
149
+ Public datasets on HF Hub are free (unlimited storage).
150
+ Private datasets require a paid HF tier.
151
+
152
+ Args:
153
+ size_bytes: Size of the data in bytes.
154
+
155
+ Returns:
156
+ 0.0 for public datasets, None for private (varies by tier).
157
+ """
158
+ if not self.private:
159
+ return 0.0
160
+ return None
@@ -0,0 +1,134 @@
1
+ """StorageBackend protocol -- interface that all storage backends implement.
2
+
3
+ Every storage destination implements this protocol. The upload manager
4
+ dispatches uploads to backends through this uniform interface.
5
+
6
+ All backends share the same upload pipeline:
7
+ [User approves in review UI] -> Compress (tar.zst) -> Queue -> Upload Worker
8
+ |
9
+ Backend-specific:
10
+ - S3: multipart upload
11
+ - HF Hub: git lfs push
12
+ - R2: S3-compatible multipart
13
+ - Wormhole: P2P direct
14
+
15
+ See design doc Section 7.3 for the full protocol specification.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Protocol, runtime_checkable
23
+
24
+
25
+ @dataclass
26
+ class UploadResult:
27
+ """Result of an upload operation.
28
+
29
+ Attributes:
30
+ success: Whether the upload succeeded.
31
+ remote_url: URL or URI of the uploaded resource (if applicable).
32
+ bytes_sent: Total bytes sent.
33
+ error: Error message if the upload failed.
34
+ metadata: Additional backend-specific metadata.
35
+ """
36
+
37
+ success: bool
38
+ remote_url: str = ""
39
+ bytes_sent: int = 0
40
+ error: str = ""
41
+ metadata: dict = field(default_factory=dict)
42
+
43
+
44
+ @dataclass
45
+ class UploadRecord:
46
+ """Record of a past upload, for listing/auditing.
47
+
48
+ Attributes:
49
+ recording_id: The capture session ID that was uploaded.
50
+ backend: Name of the storage backend used.
51
+ remote_url: URL or URI of the uploaded resource.
52
+ uploaded_at: ISO 8601 timestamp of the upload.
53
+ size_bytes: Size of the uploaded archive.
54
+ """
55
+
56
+ recording_id: str
57
+ backend: str
58
+ remote_url: str
59
+ uploaded_at: str
60
+ size_bytes: int
61
+
62
+
63
+ @runtime_checkable
64
+ class StorageBackend(Protocol):
65
+ """Protocol that every storage backend must implement.
66
+
67
+ Backends provide upload, optional delete, optional list, credential
68
+ verification, and cost estimation.
69
+
70
+ Attributes:
71
+ name: Human-readable backend name (e.g., "s3", "huggingface").
72
+ supports_delete: Whether uploaded resources can be deleted.
73
+ supports_list: Whether uploaded resources can be listed.
74
+ """
75
+
76
+ name: str
77
+ supports_delete: bool
78
+ supports_list: bool
79
+
80
+ def upload(self, archive_path: Path, metadata: dict) -> UploadResult:
81
+ """Upload a capture archive to the backend.
82
+
83
+ Args:
84
+ archive_path: Path to the tar.zst archive to upload.
85
+ metadata: Capture metadata (id, duration, event count, etc.).
86
+
87
+ Returns:
88
+ UploadResult with success status and remote URL.
89
+ """
90
+ ...
91
+
92
+ def delete(self, recording_id: str) -> bool:
93
+ """Delete a previously uploaded recording.
94
+
95
+ Args:
96
+ recording_id: The capture session ID to delete.
97
+
98
+ Returns:
99
+ True if deletion succeeded.
100
+
101
+ Raises:
102
+ NotImplementedError: If the backend does not support deletion.
103
+ """
104
+ ...
105
+
106
+ def list_uploads(self) -> list[UploadRecord]:
107
+ """List all uploads made through this backend.
108
+
109
+ Returns:
110
+ List of UploadRecord objects.
111
+
112
+ Raises:
113
+ NotImplementedError: If the backend does not support listing.
114
+ """
115
+ ...
116
+
117
+ def verify_credentials(self) -> bool:
118
+ """Verify that the backend credentials are valid.
119
+
120
+ Returns:
121
+ True if credentials are valid and the backend is reachable.
122
+ """
123
+ ...
124
+
125
+ def estimate_cost(self, size_bytes: int) -> float | None:
126
+ """Estimate the cost of uploading/storing the given amount of data.
127
+
128
+ Args:
129
+ size_bytes: Size of the data in bytes.
130
+
131
+ Returns:
132
+ Estimated cost in USD, or None if cost estimation is not available.
133
+ """
134
+ ...