dosync 0.4.2__tar.gz → 0.4.3__tar.gz

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.
Files changed (136) hide show
  1. {dosync-0.4.2 → dosync-0.4.3}/PKG-INFO +1 -1
  2. {dosync-0.4.2 → dosync-0.4.3}/dosync/__init__.py +1 -1
  3. {dosync-0.4.2 → dosync-0.4.3}/dosync/cli.py +18 -0
  4. {dosync-0.4.2 → dosync-0.4.3}/dosync/declarative.py +2 -1
  5. {dosync-0.4.2 → dosync-0.4.3}/dosync/hub.py +8 -3
  6. dosync-0.4.3/dosync/paths.py +224 -0
  7. {dosync-0.4.2 → dosync-0.4.3}/dosync/policy_config.py +3 -1
  8. {dosync-0.4.2 → dosync-0.4.3}/dosync/security.py +2 -1
  9. {dosync-0.4.2 → dosync-0.4.3}/dosync/server.py +8 -2
  10. {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/PKG-INFO +1 -1
  11. {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/SOURCES.txt +2 -0
  12. dosync-0.4.3/tests/test_deployment_layout.py +209 -0
  13. {dosync-0.4.2 → dosync-0.4.3}/LICENSE +0 -0
  14. {dosync-0.4.2 → dosync-0.4.3}/README.md +0 -0
  15. {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/__init__.py +0 -0
  16. {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/ble.py +0 -0
  17. {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/declarative.py +0 -0
  18. {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/homeassistant.py +0 -0
  19. {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/matter.py +0 -0
  20. {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/mavlink.py +0 -0
  21. {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/mqtt.py +0 -0
  22. {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/notifications.py +0 -0
  23. {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/shelly.py +0 -0
  24. {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/wiz.py +0 -0
  25. {dosync-0.4.2 → dosync-0.4.3}/dosync/audit_backup.py +0 -0
  26. {dosync-0.4.2 → dosync-0.4.3}/dosync/auth.py +0 -0
  27. {dosync-0.4.2 → dosync-0.4.3}/dosync/auth_fastapi.py +0 -0
  28. {dosync-0.4.2 → dosync-0.4.3}/dosync/cert_signing.py +0 -0
  29. {dosync-0.4.2 → dosync-0.4.3}/dosync/certify.py +0 -0
  30. {dosync-0.4.2 → dosync-0.4.3}/dosync/composite_operations.py +0 -0
  31. {dosync-0.4.2 → dosync-0.4.3}/dosync/config_reference.py +0 -0
  32. {dosync-0.4.2 → dosync-0.4.3}/dosync/dashboard.html +0 -0
  33. {dosync-0.4.2 → dosync-0.4.3}/dosync/db.py +0 -0
  34. {dosync-0.4.2 → dosync-0.4.3}/dosync/device_arbiter.py +0 -0
  35. {dosync-0.4.2 → dosync-0.4.3}/dosync/discovery.py +0 -0
  36. {dosync-0.4.2 → dosync-0.4.3}/dosync/ed25519_pure.py +0 -0
  37. {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/__init__.py +0 -0
  38. {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/3d-printer.yaml +0 -0
  39. {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/air-conditioner.yaml +0 -0
  40. {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/building-lighting.json +0 -0
  41. {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/industrial-conveyor.yaml +0 -0
  42. {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/light-generic.yaml +0 -0
  43. {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/television.yaml +0 -0
  44. {dosync-0.4.2 → dosync-0.4.3}/dosync/executor.py +0 -0
  45. {dosync-0.4.2 → dosync-0.4.3}/dosync/geo.py +0 -0
  46. {dosync-0.4.2 → dosync-0.4.3}/dosync/hub_monitor.py +0 -0
  47. {dosync-0.4.2 → dosync-0.4.3}/dosync/lightweight.py +0 -0
  48. {dosync-0.4.2 → dosync-0.4.3}/dosync/manage.py +0 -0
  49. {dosync-0.4.2 → dosync-0.4.3}/dosync/mcp_server.py +0 -0
  50. {dosync-0.4.2 → dosync-0.4.3}/dosync/metrics.py +0 -0
  51. {dosync-0.4.2 → dosync-0.4.3}/dosync/models.py +0 -0
  52. {dosync-0.4.2 → dosync-0.4.3}/dosync/operation_guards.py +0 -0
  53. {dosync-0.4.2 → dosync-0.4.3}/dosync/operation_supervisor.py +0 -0
  54. {dosync-0.4.2 → dosync-0.4.3}/dosync/operations.py +0 -0
  55. {dosync-0.4.2 → dosync-0.4.3}/dosync/plugins.py +0 -0
  56. {dosync-0.4.2 → dosync-0.4.3}/dosync/policies.py +0 -0
  57. {dosync-0.4.2 → dosync-0.4.3}/dosync/py.typed +0 -0
  58. {dosync-0.4.2 → dosync-0.4.3}/dosync/reconciler.py +0 -0
  59. {dosync-0.4.2 → dosync-0.4.3}/dosync/route_composer.py +0 -0
  60. {dosync-0.4.2 → dosync-0.4.3}/dosync/spec_coverage.py +0 -0
  61. {dosync-0.4.2 → dosync-0.4.3}/dosync/validation.py +0 -0
  62. {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/dependency_links.txt +0 -0
  63. {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/entry_points.txt +0 -0
  64. {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/requires.txt +0 -0
  65. {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/top_level.txt +0 -0
  66. {dosync-0.4.2 → dosync-0.4.3}/pyproject.toml +0 -0
  67. {dosync-0.4.2 → dosync-0.4.3}/setup.cfg +0 -0
  68. {dosync-0.4.2 → dosync-0.4.3}/tests/test_adapters.py +0 -0
  69. {dosync-0.4.2 → dosync-0.4.3}/tests/test_audit_archive.py +0 -0
  70. {dosync-0.4.2 → dosync-0.4.3}/tests/test_audit_backup.py +0 -0
  71. {dosync-0.4.2 → dosync-0.4.3}/tests/test_audit_chain_integrity.py +0 -0
  72. {dosync-0.4.2 → dosync-0.4.3}/tests/test_audit_provenance.py +0 -0
  73. {dosync-0.4.2 → dosync-0.4.3}/tests/test_auth.py +0 -0
  74. {dosync-0.4.2 → dosync-0.4.3}/tests/test_ble_adapter.py +0 -0
  75. {dosync-0.4.2 → dosync-0.4.3}/tests/test_certification_honesty.py +0 -0
  76. {dosync-0.4.2 → dosync-0.4.3}/tests/test_claim_state_machine.py +0 -0
  77. {dosync-0.4.2 → dosync-0.4.3}/tests/test_composite_operations.py +0 -0
  78. {dosync-0.4.2 → dosync-0.4.3}/tests/test_composite_orchestration.py +0 -0
  79. {dosync-0.4.2 → dosync-0.4.3}/tests/test_composition_kind_db.py +0 -0
  80. {dosync-0.4.2 → dosync-0.4.3}/tests/test_composition_kind_endpoint.py +0 -0
  81. {dosync-0.4.2 → dosync-0.4.3}/tests/test_composition_routing.py +0 -0
  82. {dosync-0.4.2 → dosync-0.4.3}/tests/test_db.py +0 -0
  83. {dosync-0.4.2 → dosync-0.4.3}/tests/test_declarative_adapters.py +0 -0
  84. {dosync-0.4.2 → dosync-0.4.3}/tests/test_declarative_quarantine.py +0 -0
  85. {dosync-0.4.2 → dosync-0.4.3}/tests/test_deployment_env_contract.py +0 -0
  86. {dosync-0.4.2 → dosync-0.4.3}/tests/test_device_health.py +0 -0
  87. {dosync-0.4.2 → dosync-0.4.3}/tests/test_device_heartbeat.py +0 -0
  88. {dosync-0.4.2 → dosync-0.4.3}/tests/test_direct_action_governance.py +0 -0
  89. {dosync-0.4.2 → dosync-0.4.3}/tests/test_discovery_adoption.py +0 -0
  90. {dosync-0.4.2 → dosync-0.4.3}/tests/test_drone_policies.py +0 -0
  91. {dosync-0.4.2 → dosync-0.4.3}/tests/test_ed25519_pure.py +0 -0
  92. {dosync-0.4.2 → dosync-0.4.3}/tests/test_emergency_preemption.py +0 -0
  93. {dosync-0.4.2 → dosync-0.4.3}/tests/test_event_loop_migration.py +0 -0
  94. {dosync-0.4.2 → dosync-0.4.3}/tests/test_explain_consistency.py +0 -0
  95. {dosync-0.4.2 → dosync-0.4.3}/tests/test_geo.py +0 -0
  96. {dosync-0.4.2 → dosync-0.4.3}/tests/test_ha_bridge_hygiene.py +0 -0
  97. {dosync-0.4.2 → dosync-0.4.3}/tests/test_hub_monitor.py +0 -0
  98. {dosync-0.4.2 → dosync-0.4.3}/tests/test_idempotency.py +0 -0
  99. {dosync-0.4.2 → dosync-0.4.3}/tests/test_independent_observation.py +0 -0
  100. {dosync-0.4.2 → dosync-0.4.3}/tests/test_integration_suite.py +0 -0
  101. {dosync-0.4.2 → dosync-0.4.3}/tests/test_lightweight_heartbeat.py +0 -0
  102. {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_adapter.py +0 -0
  103. {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_channels.py +0 -0
  104. {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_listener.py +0 -0
  105. {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_return_home.py +0 -0
  106. {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_single_reader.py +0 -0
  107. {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_telemetry_closure.py +0 -0
  108. {dosync-0.4.2 → dosync-0.4.3}/tests/test_mcp_dynamic_intents.py +0 -0
  109. {dosync-0.4.2 → dosync-0.4.3}/tests/test_mcp_partial_progress.py +0 -0
  110. {dosync-0.4.2 → dosync-0.4.3}/tests/test_metrics.py +0 -0
  111. {dosync-0.4.2 → dosync-0.4.3}/tests/test_models.py +0 -0
  112. {dosync-0.4.2 → dosync-0.4.3}/tests/test_multihub_endpoints.py +0 -0
  113. {dosync-0.4.2 → dosync-0.4.3}/tests/test_operation_guards.py +0 -0
  114. {dosync-0.4.2 → dosync-0.4.3}/tests/test_operation_supervisor.py +0 -0
  115. {dosync-0.4.2 → dosync-0.4.3}/tests/test_operations.py +0 -0
  116. {dosync-0.4.2 → dosync-0.4.3}/tests/test_operations_endpoints.py +0 -0
  117. {dosync-0.4.2 → dosync-0.4.3}/tests/test_operations_persistence.py +0 -0
  118. {dosync-0.4.2 → dosync-0.4.3}/tests/test_operations_wiring.py +0 -0
  119. {dosync-0.4.2 → dosync-0.4.3}/tests/test_panel_polish_2026_07_21.py +0 -0
  120. {dosync-0.4.2 → dosync-0.4.3}/tests/test_policies.py +0 -0
  121. {dosync-0.4.2 → dosync-0.4.3}/tests/test_policy_config.py +0 -0
  122. {dosync-0.4.2 → dosync-0.4.3}/tests/test_pushed_verification.py +0 -0
  123. {dosync-0.4.2 → dosync-0.4.3}/tests/test_reachability_cause.py +0 -0
  124. {dosync-0.4.2 → dosync-0.4.3}/tests/test_recall_benchmark_postpolicy.py +0 -0
  125. {dosync-0.4.2 → dosync-0.4.3}/tests/test_reconciler.py +0 -0
  126. {dosync-0.4.2 → dosync-0.4.3}/tests/test_resolution_wiring.py +0 -0
  127. {dosync-0.4.2 → dosync-0.4.3}/tests/test_resolver_scoring.py +0 -0
  128. {dosync-0.4.2 → dosync-0.4.3}/tests/test_resolver_semantics.py +0 -0
  129. {dosync-0.4.2 → dosync-0.4.3}/tests/test_route_composer.py +0 -0
  130. {dosync-0.4.2 → dosync-0.4.3}/tests/test_sensor_kind.py +0 -0
  131. {dosync-0.4.2 → dosync-0.4.3}/tests/test_server.py +0 -0
  132. {dosync-0.4.2 → dosync-0.4.3}/tests/test_telemetry_bridge.py +0 -0
  133. {dosync-0.4.2 → dosync-0.4.3}/tests/test_third_party_adapters.py +0 -0
  134. {dosync-0.4.2 → dosync-0.4.3}/tests/test_validation.py +0 -0
  135. {dosync-0.4.2 → dosync-0.4.3}/tests/test_validation_integration.py +0 -0
  136. {dosync-0.4.2 → dosync-0.4.3}/tests/test_wiring_audit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dosync
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: The semantic layer between AI agents and physical devices
5
5
  Author-email: Rodrigo Giuliani <rgiuliani@dosync.dev>
6
6
  License-Expression: Apache-2.0
@@ -13,5 +13,5 @@ The two numbers move independently on purpose:
13
13
  __version__ this implementation of the hub (semver)
14
14
  __protocol_version__ the wire contract other implementations must match
15
15
  """
16
- __version__ = "0.4.2"
16
+ __version__ = "0.4.3"
17
17
  __protocol_version__ = "0.4"
@@ -53,6 +53,24 @@ def hub(argv=None) -> None:
53
53
  os.environ["DOSYNC_PORT"] = str(args.port)
54
54
  os.environ["DOSYNC_HOST"] = args.host
55
55
 
56
+ # Binding to loopback is the right default — a hub reachable from the whole
57
+ # network because nobody chose that is worse than one that needs a flag. But
58
+ # the commonest deployment is a headless Raspberry Pi whose operator is on
59
+ # SSH and wants the dashboard from their laptop, and "Uvicorn running on
60
+ # http://127.0.0.1" does not tell them why their browser cannot connect.
61
+ #
62
+ # Found by installing from PyPI on a clean machine and trying to open the
63
+ # dashboard from another one. The default does not change; the silence does.
64
+ if args.host in ("127.0.0.1", "localhost", "::1"):
65
+ print()
66
+ print(" Listening on loopback only — reachable from this machine at")
67
+ print(f" http://localhost:{args.port}")
68
+ print(" To reach it from another machine on your network:")
69
+ print(f" dosync-hub --host 0.0.0.0 --port {args.port}")
70
+ print(" (that exposes the hub to your local network; it keeps requiring")
71
+ print(" a token, and TLS is a separate step — see setup_pki.sh)")
72
+ print()
73
+
56
74
  uvicorn.run("dosync.server:app", host=args.host, port=args.port,
57
75
  reload=args.reload, log_level=args.log_level)
58
76
 
@@ -281,7 +281,8 @@ def load_directory(directory: str = None) -> list[tuple[Any, dict]]:
281
281
  the hub running to fix it.
282
282
  """
283
283
  if directory is None:
284
- directory = os.environ.get("DOSYNC_DECLARATIVE_DIR", "declarative")
284
+ from .paths import resolve_config_dir
285
+ directory = str(resolve_config_dir("declarative", "DOSYNC_DECLARATIVE_DIR"))
285
286
  path = Path(directory)
286
287
  if not path.is_dir():
287
288
  return []
@@ -1639,7 +1639,9 @@ class DoSyncHub:
1639
1639
  "A rewritten history will not be detectable.", interval)
1640
1640
  return
1641
1641
  if directory is None:
1642
- directory = os.environ.get("DOSYNC_CHECKPOINT_DIR", "checkpoints")
1642
+ from .paths import resolve_state
1643
+ directory = str(resolve_state("checkpoints", "DOSYNC_CHECKPOINT_DIR",
1644
+ create=True))
1643
1645
 
1644
1646
  _export = os.environ.get("DOSYNC_CHECKPOINT_EXPORT_DIR")
1645
1647
  log.info("Audit checkpoints scheduled every %.0fs → %s", interval, directory)
@@ -1699,7 +1701,9 @@ class DoSyncHub:
1699
1701
  from . import audit_backup, cert_signing
1700
1702
 
1701
1703
  if directory is None:
1702
- directory = os.environ.get("DOSYNC_CHECKPOINT_DIR", "checkpoints")
1704
+ from .paths import resolve_state
1705
+ directory = str(resolve_state("checkpoints", "DOSYNC_CHECKPOINT_DIR",
1706
+ create=True))
1703
1707
 
1704
1708
  # The mark must be current before attesting to it.
1705
1709
  self.audit_log.flush_head()
@@ -1832,7 +1836,8 @@ class DoSyncHub:
1832
1836
  if keep <= 0:
1833
1837
  return None
1834
1838
  if directory is None:
1835
- directory = os.environ.get("DOSYNC_ARCHIVE_DIR", "audit-segments")
1839
+ from .paths import resolve_state
1840
+ directory = str(resolve_state("audit-segments", "DOSYNC_ARCHIVE_DIR", create=True))
1836
1841
 
1837
1842
  entries = self.audit_log.entries()
1838
1843
  if len(entries) <= keep:
@@ -0,0 +1,224 @@
1
+ """Where a DoSync deployment keeps its configuration and its state.
2
+
3
+ Found while preparing to reflash the reference deployment: its configuration
4
+ lived in nine places, four of which the author did not remember existed, and
5
+ three of those were **inside a git clone**. A `git clean -fdx` — a command people
6
+ run to tidy a repository — would have destroyed a 42,000-entry audit chain and
7
+ the CA's private key.
8
+
9
+ That is worth stating plainly, because it is a contradiction: this protocol
10
+ argues that its evidence survives someone with root access, and until now it did
11
+ not survive someone tidying up. The sophisticated threat was covered and the
12
+ trivial one was not.
13
+
14
+ Two modes, because there are two ways to install
15
+ ------------------------------------------------
16
+ `pipx install dosync` runs as an ordinary user, who cannot write to `/etc` or
17
+ `/var/lib`. A systemd service running as root can and should. Rather than pick
18
+ one and break the other, paths cascade: the user location first, the system
19
+ location second. The same binary serves both, and neither needs configuration to
20
+ work.
21
+
22
+ Configuration ~/.config/dosync/ → /etc/dosync/
23
+ State ~/.local/state/dosync/ → /var/lib/dosync/
24
+ PKI <state>/certs/ (mode 0700)
25
+
26
+ The split follows the XDG categories, and the deciding question is the one that
27
+ specification asks: *is this datum unique to this machine?* An audit chain and a
28
+ private CA are — they are state. Policies and declarative device files are not;
29
+ they are edited by hand and can be copied between machines, so they are
30
+ configuration.
31
+
32
+ Compatibility is not optional here
33
+ ----------------------------------
34
+ A deployment exists with 42,000 entries and certificates in use. If the hub
35
+ stopped looking where that deployment keeps its data, it would come up tomorrow
36
+ with an empty chain and a fresh CA — and the history this project spent weeks
37
+ protecting would be orphaned in a directory nobody looks at.
38
+
39
+ So: an explicit variable always wins, an existing database in the working
40
+ directory keeps being used with a warning explaining how to move it, and finding
41
+ data in two places is an error rather than a choice. Choosing wrong there would
42
+ mean writing to one chain while auditing another.
43
+ """
44
+ import logging
45
+ import os
46
+ from pathlib import Path
47
+
48
+ log = logging.getLogger("dosync.paths")
49
+
50
+ #: Set by the packaging or the operator to force system mode. Without it, the
51
+ #: mode is inferred: root implies a system service.
52
+ MODE_ENV = "DOSYNC_INSTALL_MODE"
53
+
54
+
55
+ def _running_as_root() -> bool:
56
+ try:
57
+ return os.geteuid() == 0
58
+ except AttributeError: # pragma: no cover - non-POSIX
59
+ return False
60
+
61
+
62
+ def install_mode() -> str:
63
+ """`"system"` or `"user"`.
64
+
65
+ Inferred from the effective user rather than declared, because the common
66
+ cases decide themselves: a systemd unit runs as root, `pipx` does not.
67
+ `DOSYNC_INSTALL_MODE` overrides for the cases that do not — a service
68
+ running under a dedicated unprivileged account, for instance.
69
+ """
70
+ declared = os.environ.get(MODE_ENV, "").strip().lower()
71
+ if declared in ("system", "user"):
72
+ return declared
73
+ return "system" if _running_as_root() else "user"
74
+
75
+
76
+ def config_dirs() -> list[Path]:
77
+ """Where configuration is looked for, in order of preference."""
78
+ if install_mode() == "system":
79
+ return [Path("/etc/dosync")]
80
+ xdg = os.environ.get("XDG_CONFIG_HOME")
81
+ home = Path(xdg) if xdg else Path.home() / ".config"
82
+ return [home / "dosync", Path("/etc/dosync")]
83
+
84
+
85
+ def state_dir() -> Path:
86
+ """Where this deployment's own data lives — chain, PKI, evidence."""
87
+ if install_mode() == "system":
88
+ return Path("/var/lib/dosync")
89
+ xdg = os.environ.get("XDG_STATE_HOME")
90
+ home = Path(xdg) if xdg else Path.home() / ".local" / "state"
91
+ return home / "dosync"
92
+
93
+
94
+ def _legacy(name: str) -> Path:
95
+ """The pre-0.4.3 location: relative to the working directory."""
96
+ return Path.cwd() / name
97
+
98
+
99
+ def resolve_state(name: str, env_var: str = None, create: bool = False) -> Path:
100
+ """Resolve a state path, honouring an existing deployment above all else.
101
+
102
+ Order:
103
+ 1. `env_var`, if set — an operator who said where wins, always.
104
+ 2. The legacy path, **if it already has data**. A running deployment does
105
+ not lose its chain to an upgrade.
106
+ 3. The state directory.
107
+
108
+ Finding data in both the legacy path and the state directory raises, rather
109
+ than picking. Picking wrong means writing to one chain and auditing another,
110
+ and the operator is the only one who knows which is current.
111
+ """
112
+ if env_var:
113
+ explicit = os.environ.get(env_var)
114
+ if explicit:
115
+ return Path(explicit)
116
+
117
+ legacy, modern = _legacy(name), state_dir() / name
118
+ legacy_has = legacy.exists() and (legacy.is_file() or any(legacy.iterdir()))
119
+ modern_has = modern.exists() and (modern.is_file() or any(modern.iterdir()))
120
+
121
+ if legacy_has and modern_has:
122
+ raise RuntimeError(
123
+ f"'{name}' exists in two places and DoSync will not guess which is "
124
+ f"current:\n {legacy}\n {modern}\n"
125
+ f"Keep one, or set {env_var or 'the matching DOSYNC_* variable'} to "
126
+ f"say which. Choosing wrong here would mean writing to one and "
127
+ f"auditing the other.")
128
+
129
+ if legacy_has:
130
+ log.warning(
131
+ "Using %s from the working directory. Since 0.4.3 this belongs in "
132
+ "%s — data inside a source tree is one `git clean` from being gone. "
133
+ "Move it there, or set %s to keep it where it is.",
134
+ name, modern, env_var or "the matching variable")
135
+ return legacy
136
+
137
+ if create:
138
+ modern.parent.mkdir(parents=True, exist_ok=True)
139
+ return modern
140
+
141
+
142
+ def resolve_config(name: str, env_var: str = None) -> Path | None:
143
+ """Find a configuration file, or None if there is none.
144
+
145
+ Returns None rather than a non-existent path: absent configuration is a
146
+ normal state for this protocol — a hub with no deployment policies is
147
+ conforming — and a caller should not have to distinguish "missing file" from
148
+ "file that happens not to exist yet".
149
+ """
150
+ if env_var:
151
+ explicit = os.environ.get(env_var)
152
+ if explicit:
153
+ return Path(explicit)
154
+
155
+ legacy = _legacy(name)
156
+ if legacy.exists():
157
+ return legacy
158
+ for d in config_dirs():
159
+ candidate = d / name
160
+ if candidate.exists():
161
+ return candidate
162
+ return None
163
+
164
+
165
+ def resolve_config_dir(name: str, env_var: str = None) -> Path:
166
+ """A configuration DIRECTORY — declarative device files, for instance.
167
+
168
+ Unlike `resolve_config`, always returns a path: the caller iterates it and
169
+ an empty or absent directory is a legitimate answer meaning "no declarative
170
+ devices", not an error.
171
+ """
172
+ if env_var:
173
+ explicit = os.environ.get(env_var)
174
+ if explicit:
175
+ return Path(explicit)
176
+
177
+ legacy = _legacy(name)
178
+ if legacy.is_dir() and any(legacy.iterdir()):
179
+ return legacy
180
+ for d in config_dirs():
181
+ candidate = d / name
182
+ if candidate.is_dir():
183
+ return candidate
184
+ return config_dirs()[0] / name
185
+
186
+
187
+ def certs_dir() -> Path:
188
+ """The PKI directory, created 0700 when this call creates it.
189
+
190
+ A private key in a directory with whatever permissions it inherited is the
191
+ kind of detail that is invisible until an audit asks about it.
192
+ """
193
+ explicit = os.environ.get("DOSYNC_CERTS_DIR")
194
+ if explicit:
195
+ return Path(explicit)
196
+
197
+ legacy = _legacy("certs")
198
+ if legacy.exists() and any(legacy.iterdir()):
199
+ return legacy
200
+
201
+ d = state_dir() / "certs"
202
+ if not d.exists():
203
+ d.mkdir(parents=True, exist_ok=True)
204
+ try:
205
+ d.chmod(0o700)
206
+ except OSError as e: # pragma: no cover - exotic filesystems
207
+ log.warning("Could not restrict permissions on %s: %s", d, e)
208
+ return d
209
+
210
+
211
+ def describe() -> dict:
212
+ """Every path this deployment resolved, for the startup log and /v1/status.
213
+
214
+ Reported because an operator editing `/etc/dosync/policies.json` while the
215
+ hub reads `~/.config/dosync/policies.json` believes they are protected by a
216
+ policy the hub never loaded. Silent divergence between what someone edits
217
+ and what runs is exactly the failure this project keeps finding.
218
+ """
219
+ return {
220
+ "mode": install_mode(),
221
+ "config_dirs": [str(d) for d in config_dirs()],
222
+ "state_dir": str(state_dir()),
223
+ "certs_dir": str(certs_dir()),
224
+ }
@@ -249,4 +249,6 @@ def configured_path() -> str | None:
249
249
  common state — not an error. The reference hub ships with NO deployment
250
250
  policies, because the protocol has no opinion about your house.
251
251
  """
252
- return os.environ.get("DOSYNC_POLICIES") or None
252
+ from .paths import resolve_config
253
+ found = resolve_config("policies.json", "DOSYNC_POLICIES")
254
+ return str(found) if found else None
@@ -61,7 +61,8 @@ log = logging.getLogger("dosync.security")
61
61
 
62
62
  # ── Configuración ─────────────────────────────────────────────────────────────
63
63
 
64
- CERTS_DIR = Path(os.environ.get("DOSYNC_CERTS_DIR", "certs"))
64
+ from .paths import certs_dir as _certs_dir
65
+ CERTS_DIR = _certs_dir()
65
66
  CA_KEY_PATH = CERTS_DIR / "ca.key"
66
67
  CA_CERT_PATH = CERTS_DIR / "ca.crt"
67
68
  HUB_KEY_PATH = CERTS_DIR / "hub.key"
@@ -65,7 +65,12 @@ def _resolve_db_path() -> str:
65
65
  "DOSYNC_DB_PATH is a deprecated alias for DOSYNC_DB — using %s. "
66
66
  "Rename the variable to DOSYNC_DB.", alias)
67
67
  return alias
68
- return "dosync.db"
68
+ # No explicit setting: resolve through the deployment layout, which keeps an
69
+ # existing database in the working directory rather than starting a new one
70
+ # beside it. A hub that came up with an empty chain after an upgrade would
71
+ # lose exactly the history this protocol exists to protect.
72
+ from dosync.paths import resolve_state
73
+ return str(resolve_state("dosync.db", "DOSYNC_DB", create=True))
69
74
 
70
75
 
71
76
  # ── Estado global del hub ─────────────────────────────────────────────────────
@@ -732,6 +737,7 @@ async def lifespan(app: FastAPI):
732
737
  try:
733
738
  from dosync.adapters.declarative import DeclarativeAdapter
734
739
  from dosync.declarative import load_directory
740
+ from dosync.paths import resolve_config_dir
735
741
 
736
742
  _declared = load_directory()
737
743
  if _declared:
@@ -751,7 +757,7 @@ async def lifespan(app: FastAPI):
751
757
  hub.register_device(_manifest)
752
758
  log.info("Declarative adapters: %d device(s) registered from %s",
753
759
  len(_declared),
754
- os.environ.get("DOSYNC_DECLARATIVE_DIR", "declarative"))
760
+ str(resolve_config_dir("declarative", "DOSYNC_DECLARATIVE_DIR")))
755
761
 
756
762
  # A device whose file is gone must not keep answering intents. It is
757
763
  # QUARANTINED rather than deleted: a directory that failed to mount looks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dosync
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: The semantic layer between AI agents and physical devices
5
5
  Author-email: Rodrigo Giuliani <rgiuliani@dosync.dev>
6
6
  License-Expression: Apache-2.0
@@ -28,6 +28,7 @@ dosync/models.py
28
28
  dosync/operation_guards.py
29
29
  dosync/operation_supervisor.py
30
30
  dosync/operations.py
31
+ dosync/paths.py
31
32
  dosync/plugins.py
32
33
  dosync/policies.py
33
34
  dosync/policy_config.py
@@ -79,6 +80,7 @@ tests/test_db.py
79
80
  tests/test_declarative_adapters.py
80
81
  tests/test_declarative_quarantine.py
81
82
  tests/test_deployment_env_contract.py
83
+ tests/test_deployment_layout.py
82
84
  tests/test_device_health.py
83
85
  tests/test_device_heartbeat.py
84
86
  tests/test_direct_action_governance.py
@@ -0,0 +1,209 @@
1
+ """Where a deployment keeps its configuration and its state (2026-08-08).
2
+
3
+ Found while preparing to reflash the reference hub: its configuration lived in
4
+ nine places, four of which the author did not remember existed, and three of
5
+ those were inside a git clone. A `git clean -fdx` would have destroyed a
6
+ 42,000-entry audit chain and the CA's private key.
7
+
8
+ Worth naming as the contradiction it was: this protocol argues that its evidence
9
+ survives someone with root access, and it did not survive someone tidying a
10
+ repository. The sophisticated threat was covered and the trivial one was not.
11
+
12
+ The rules below are the panel's, and each exists because getting it wrong has a
13
+ specific cost.
14
+ """
15
+ import os
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ from dosync import paths
21
+
22
+ REPO = Path(__file__).resolve().parent.parent
23
+
24
+
25
+ @pytest.fixture
26
+ def clean_env(tmp_path, monkeypatch):
27
+ """A deployment rooted entirely under tmp_path, in user mode."""
28
+ monkeypatch.setenv("DOSYNC_INSTALL_MODE", "user")
29
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
30
+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
31
+ for v in ("DOSYNC_DB", "DOSYNC_CERTS_DIR", "DOSYNC_POLICIES",
32
+ "DOSYNC_CHECKPOINT_DIR", "DOSYNC_ARCHIVE_DIR",
33
+ "DOSYNC_DECLARATIVE_DIR"):
34
+ monkeypatch.delenv(v, raising=False)
35
+ work = tmp_path / "work"
36
+ work.mkdir()
37
+ monkeypatch.chdir(work)
38
+ return tmp_path, work
39
+
40
+
41
+ # ── The two modes ───────────────────────────────────────────────────────────
42
+
43
+ def test_user_and_system_modes_resolve_differently(monkeypatch):
44
+ """`pipx` installs as an ordinary user who cannot write to /etc or
45
+ /var/lib; a systemd unit runs as root and should use them. Picking one
46
+ layout would have broken the other."""
47
+ monkeypatch.setenv("DOSYNC_INSTALL_MODE", "system")
48
+ assert paths.state_dir() == Path("/var/lib/dosync")
49
+ assert Path("/etc/dosync") in paths.config_dirs()
50
+
51
+ monkeypatch.setenv("DOSYNC_INSTALL_MODE", "user")
52
+ monkeypatch.setenv("XDG_STATE_HOME", "/tmp/xdg-state")
53
+ assert paths.state_dir() == Path("/tmp/xdg-state/dosync")
54
+
55
+
56
+ def test_config_cascades_from_user_to_system(clean_env, monkeypatch):
57
+ """User location first, system second — so a per-user install finds its own
58
+ file, and falls back to a system-wide default when there is none."""
59
+ tmp, _ = clean_env
60
+ dirs = paths.config_dirs()
61
+ assert dirs[0] == tmp / "cfg" / "dosync"
62
+ assert dirs[-1] == Path("/etc/dosync")
63
+
64
+
65
+ # ── Compatibility: the rule that protects the existing deployment ───────────
66
+
67
+ def test_an_explicit_variable_always_wins(clean_env, monkeypatch):
68
+ """An operator who said where wins over any inference. Every existing
69
+ deployment is configured this way and must be unaffected."""
70
+ monkeypatch.setenv("DOSYNC_DB", "/tmp/chosen.db")
71
+ assert paths.resolve_state("dosync.db", "DOSYNC_DB") == Path("/tmp/chosen.db")
72
+
73
+
74
+ def test_an_existing_database_in_the_working_directory_keeps_being_used(clean_env):
75
+ """The rule that matters most. A running deployment with 42,000 entries must
76
+ not come up with an empty chain because the layout changed — that would lose
77
+ exactly the history this protocol exists to protect."""
78
+ _, work = clean_env
79
+ (work / "dosync.db").write_text("existing deployment")
80
+
81
+ assert paths.resolve_state("dosync.db", "DOSYNC_DB") == work / "dosync.db"
82
+
83
+
84
+ def test_the_legacy_path_is_used_with_a_warning_not_silently(clean_env, caplog):
85
+ """Using it quietly would leave the operator unaware their data sits inside
86
+ a source tree."""
87
+ import logging
88
+
89
+ _, work = clean_env
90
+ (work / "dosync.db").write_text("x")
91
+ with caplog.at_level(logging.WARNING):
92
+ paths.resolve_state("dosync.db", "DOSYNC_DB")
93
+ assert any("git clean" in str(r.msg) or "working directory" in str(r.msg)
94
+ for r in caplog.records)
95
+
96
+
97
+ def test_data_in_two_places_is_an_error_not_a_choice(clean_env):
98
+ """Choosing wrong means writing to one chain and auditing the other, and
99
+ only the operator knows which is current."""
100
+ tmp, work = clean_env
101
+ (work / "dosync.db").write_text("old")
102
+ modern = paths.state_dir()
103
+ modern.mkdir(parents=True, exist_ok=True)
104
+ (modern / "dosync.db").write_text("new")
105
+
106
+ with pytest.raises(RuntimeError) as e:
107
+ paths.resolve_state("dosync.db", "DOSYNC_DB")
108
+ assert "will not guess" in str(e.value)
109
+ assert str(work) in str(e.value) and str(modern) in str(e.value), \
110
+ "the error must name both paths — the operator has to go look at them"
111
+
112
+
113
+ def test_a_clean_install_uses_the_state_directory(clean_env):
114
+ tmp, _ = clean_env
115
+ p = paths.resolve_state("dosync.db", "DOSYNC_DB")
116
+ assert str(p).startswith(str(paths.state_dir()))
117
+
118
+
119
+ # ── PKI ─────────────────────────────────────────────────────────────────────
120
+
121
+ def test_the_pki_directory_is_created_private(clean_env):
122
+ """A private key in a directory with whatever permissions it inherited is
123
+ the kind of detail nobody notices until an audit asks."""
124
+ d = paths.certs_dir()
125
+ assert d.exists()
126
+ assert oct(d.stat().st_mode)[-3:] == "700", \
127
+ f"certs directory is {oct(d.stat().st_mode)[-3:]}, expected 700"
128
+
129
+
130
+ def test_existing_certificates_are_not_abandoned(clean_env):
131
+ """Regenerating a CA orphans every device certificate issued from it, and
132
+ invalidates the certificate an operator installed on their laptop."""
133
+ _, work = clean_env
134
+ legacy = work / "certs"
135
+ legacy.mkdir()
136
+ (legacy / "ca.crt").write_text("existing CA")
137
+
138
+ assert paths.certs_dir() == legacy
139
+
140
+
141
+ # ── Visibility ──────────────────────────────────────────────────────────────
142
+
143
+ def test_the_hub_can_report_which_paths_it_resolved(clean_env):
144
+ """An operator editing /etc/dosync/policies.json while the hub reads
145
+ ~/.config/dosync/policies.json believes they are protected by a policy the
146
+ hub never loaded. Silent divergence between what is edited and what runs is
147
+ the failure this project keeps finding."""
148
+ d = paths.describe()
149
+ assert set(d) == {"mode", "config_dirs", "state_dir", "certs_dir"}
150
+ assert d["mode"] in ("user", "system")
151
+
152
+
153
+ def test_configuration_is_found_in_the_config_directory(clean_env):
154
+ tmp, _ = clean_env
155
+ cfg = tmp / "cfg" / "dosync"
156
+ cfg.mkdir(parents=True)
157
+ (cfg / "policies.json").write_text("{}")
158
+
159
+ assert paths.resolve_config("policies.json", "DOSYNC_POLICIES") == \
160
+ cfg / "policies.json"
161
+
162
+
163
+ def test_absent_configuration_is_none_not_a_missing_path(clean_env):
164
+ """A hub with no deployment policies is conforming — absence is a normal
165
+ state and callers should not have to tell it from a path that happens not to
166
+ exist."""
167
+ assert paths.resolve_config("policies.json", "DOSYNC_POLICIES") is None
168
+
169
+
170
+ # ── Found by installing from PyPI on a clean machine (2026-08-08) ───────────
171
+
172
+ def test_the_hub_says_when_it_is_only_reachable_locally():
173
+ """The commonest deployment is a headless Raspberry Pi whose operator is on
174
+ SSH and wants the dashboard from their laptop. Binding to loopback is the
175
+ right default — a hub reachable from the whole network because nobody chose
176
+ that is worse than one needing a flag — but `Uvicorn running on
177
+ http://127.0.0.1` does not tell them why their browser cannot connect.
178
+
179
+ The default does not change. The silence does.
180
+ """
181
+ import inspect
182
+
183
+ from dosync import cli
184
+
185
+ src = inspect.getsource(cli)
186
+ assert "loopback only" in src
187
+ assert "--host 0.0.0.0" in src, "and must say exactly how to change it"
188
+ assert 'default=os.environ.get("DOSYNC_HOST", "127.0.0.1")' in src, \
189
+ "the safe default must stay"
190
+
191
+
192
+ def test_the_published_version_and_the_source_version_cannot_silently_diverge():
193
+ """`paths.py` was added under 0.4.2 while 0.4.2 was already on PyPI — two
194
+ different artefacts with one number. Anyone reporting a bug "in 0.4.2" would
195
+ leave us unable to tell which one they have.
196
+
197
+ This pins the specific mistake: the version must move when behaviour does.
198
+ """
199
+ import re
200
+
201
+ import dosync
202
+
203
+ changelog = (REPO / "CHANGELOG.md").read_text()
204
+ versions = re.findall(r"^## \[([0-9]+\.[0-9]+\.[0-9]+)\]", changelog, re.M)
205
+
206
+ assert dosync.__version__ in versions or dosync.__version__ > max(versions), (
207
+ f"version {dosync.__version__} is neither released nor ahead of the "
208
+ f"latest changelog entry {max(versions)} — a behaviour change under an "
209
+ f"already-published number produces two artefacts with one name")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes