@smilintux/skcapstone 0.1.0
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.
- package/.cursorrules +33 -0
- package/.github/workflows/ci.yml +23 -0
- package/.github/workflows/publish.yml +52 -0
- package/AGENTS.md +74 -0
- package/CLAUDE.md +56 -0
- package/LICENSE +674 -0
- package/README.md +242 -0
- package/SKILL.md +36 -0
- package/bin/cli.js +18 -0
- package/docs/ARCHITECTURE.md +510 -0
- package/docs/SECURITY_DESIGN.md +315 -0
- package/docs/SOVEREIGN_SINGULARITY.md +371 -0
- package/docs/TOKEN_SYSTEM.md +201 -0
- package/index.d.ts +9 -0
- package/index.js +32 -0
- package/package.json +32 -0
- package/pyproject.toml +84 -0
- package/src/skcapstone/__init__.py +13 -0
- package/src/skcapstone/cli.py +1441 -0
- package/src/skcapstone/connectors/__init__.py +6 -0
- package/src/skcapstone/coordination.py +590 -0
- package/src/skcapstone/discovery.py +275 -0
- package/src/skcapstone/memory_engine.py +457 -0
- package/src/skcapstone/models.py +223 -0
- package/src/skcapstone/pillars/__init__.py +8 -0
- package/src/skcapstone/pillars/identity.py +91 -0
- package/src/skcapstone/pillars/memory.py +61 -0
- package/src/skcapstone/pillars/security.py +83 -0
- package/src/skcapstone/pillars/sync.py +486 -0
- package/src/skcapstone/pillars/trust.py +335 -0
- package/src/skcapstone/runtime.py +190 -0
- package/src/skcapstone/skills/__init__.py +1 -0
- package/src/skcapstone/skills/syncthing_setup.py +297 -0
- package/src/skcapstone/sync/__init__.py +14 -0
- package/src/skcapstone/sync/backends.py +330 -0
- package/src/skcapstone/sync/engine.py +301 -0
- package/src/skcapstone/sync/models.py +97 -0
- package/src/skcapstone/sync/vault.py +284 -0
- package/src/skcapstone/tokens.py +439 -0
- package/tests/__init__.py +0 -0
- package/tests/conftest.py +42 -0
- package/tests/test_coordination.py +299 -0
- package/tests/test_discovery.py +57 -0
- package/tests/test_memory_engine.py +391 -0
- package/tests/test_models.py +63 -0
- package/tests/test_pillars.py +87 -0
- package/tests/test_runtime.py +60 -0
- package/tests/test_sync.py +507 -0
- package/tests/test_syncthing_setup.py +76 -0
- package/tests/test_tokens.py +265 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the sovereign sync module -- vault, backends, and engine.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import tarfile
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def agent_home(tmp_path: Path) -> Path:
|
|
17
|
+
"""Create a minimal agent home directory for testing."""
|
|
18
|
+
home = tmp_path / ".skcapstone"
|
|
19
|
+
home.mkdir()
|
|
20
|
+
|
|
21
|
+
for pillar in ("identity", "memory", "trust", "security", "config", "skills"):
|
|
22
|
+
pillar_dir = home / pillar
|
|
23
|
+
pillar_dir.mkdir()
|
|
24
|
+
|
|
25
|
+
(home / "identity" / "identity.json").write_text(
|
|
26
|
+
json.dumps({
|
|
27
|
+
"name": "TestAgent",
|
|
28
|
+
"email": "test@skcapstone.local",
|
|
29
|
+
"fingerprint": "AAAA1111BBBB2222CCCC3333DDDD4444EEEE5555",
|
|
30
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
31
|
+
"capauth_managed": False,
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
(home / "trust" / "trust.json").write_text(
|
|
36
|
+
json.dumps({"depth": 5.0, "trust_level": 0.8, "love_intensity": 0.9})
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
(home / "config" / "config.yaml").write_text("agent_name: TestAgent\n")
|
|
40
|
+
|
|
41
|
+
(home / "manifest.json").write_text(
|
|
42
|
+
json.dumps({"name": "TestAgent", "version": "0.1.0", "connectors": []})
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
for layer in ("short-term", "mid-term", "long-term"):
|
|
46
|
+
layer_dir = home / "memory" / layer
|
|
47
|
+
layer_dir.mkdir(parents=True)
|
|
48
|
+
|
|
49
|
+
(home / "memory" / "long-term" / "test-memory.json").write_text(
|
|
50
|
+
json.dumps({"content": "test memory", "created": "2026-02-23"})
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return home
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestVault:
|
|
57
|
+
"""Tests for the Vault packing/unpacking system."""
|
|
58
|
+
|
|
59
|
+
def test_pack_creates_archive(self, agent_home: Path):
|
|
60
|
+
"""Vault pack should create a .tar.gz archive."""
|
|
61
|
+
from skcapstone.sync.vault import Vault
|
|
62
|
+
|
|
63
|
+
vault = Vault(agent_home)
|
|
64
|
+
result = vault.pack(encrypt=False)
|
|
65
|
+
|
|
66
|
+
assert result.exists()
|
|
67
|
+
assert result.name.startswith("vault-")
|
|
68
|
+
assert result.name.endswith(".tar.gz")
|
|
69
|
+
|
|
70
|
+
def test_pack_includes_pillars(self, agent_home: Path):
|
|
71
|
+
"""Archive should contain pillar directories."""
|
|
72
|
+
from skcapstone.sync.vault import Vault
|
|
73
|
+
|
|
74
|
+
vault = Vault(agent_home)
|
|
75
|
+
archive_path = vault.pack(encrypt=False)
|
|
76
|
+
|
|
77
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
78
|
+
names = tar.getnames()
|
|
79
|
+
|
|
80
|
+
assert any("identity/" in n for n in names)
|
|
81
|
+
assert any("trust/" in n for n in names)
|
|
82
|
+
assert any("config/" in n for n in names)
|
|
83
|
+
assert any("manifest.json" in n for n in names)
|
|
84
|
+
|
|
85
|
+
def test_pack_creates_manifest(self, agent_home: Path):
|
|
86
|
+
"""Pack should create a companion .manifest.json file."""
|
|
87
|
+
from skcapstone.sync.vault import Vault
|
|
88
|
+
|
|
89
|
+
vault = Vault(agent_home)
|
|
90
|
+
archive_path = vault.pack(encrypt=False)
|
|
91
|
+
manifest_path = archive_path.with_suffix(".manifest.json")
|
|
92
|
+
|
|
93
|
+
assert manifest_path.exists()
|
|
94
|
+
data = json.loads(manifest_path.read_text())
|
|
95
|
+
assert data["agent_name"] == "TestAgent"
|
|
96
|
+
assert "identity" in data["pillars_included"]
|
|
97
|
+
|
|
98
|
+
def test_unpack_restores_state(self, agent_home: Path, tmp_path: Path):
|
|
99
|
+
"""Unpacking a vault should restore pillar directories."""
|
|
100
|
+
from skcapstone.sync.vault import Vault
|
|
101
|
+
|
|
102
|
+
vault = Vault(agent_home)
|
|
103
|
+
archive_path = vault.pack(encrypt=False)
|
|
104
|
+
|
|
105
|
+
restore_dir = tmp_path / "restored"
|
|
106
|
+
restore_dir.mkdir()
|
|
107
|
+
vault.unpack(archive_path, target=restore_dir)
|
|
108
|
+
|
|
109
|
+
assert (restore_dir / "identity" / "identity.json").exists()
|
|
110
|
+
assert (restore_dir / "trust" / "trust.json").exists()
|
|
111
|
+
assert (restore_dir / "manifest.json").exists()
|
|
112
|
+
|
|
113
|
+
def test_list_vaults(self, agent_home: Path):
|
|
114
|
+
"""list_vaults should return metadata for all archives."""
|
|
115
|
+
import time
|
|
116
|
+
from skcapstone.sync.vault import Vault
|
|
117
|
+
|
|
118
|
+
vault = Vault(agent_home)
|
|
119
|
+
vault.pack(encrypt=False)
|
|
120
|
+
time.sleep(1.1)
|
|
121
|
+
vault.pack(encrypt=False)
|
|
122
|
+
|
|
123
|
+
vaults = vault.list_vaults()
|
|
124
|
+
assert len(vaults) >= 2
|
|
125
|
+
|
|
126
|
+
def test_pack_excludes_pycache(self, agent_home: Path):
|
|
127
|
+
"""Archive should not contain __pycache__ or .pyc files."""
|
|
128
|
+
from skcapstone.sync.vault import Vault
|
|
129
|
+
|
|
130
|
+
pycache = agent_home / "identity" / "__pycache__"
|
|
131
|
+
pycache.mkdir()
|
|
132
|
+
(pycache / "cached.pyc").write_text("junk")
|
|
133
|
+
|
|
134
|
+
vault = Vault(agent_home)
|
|
135
|
+
archive_path = vault.pack(encrypt=False)
|
|
136
|
+
|
|
137
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
138
|
+
names = tar.getnames()
|
|
139
|
+
|
|
140
|
+
assert not any("__pycache__" in n for n in names)
|
|
141
|
+
assert not any(".pyc" in n for n in names)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestSyncthingBackend:
|
|
145
|
+
"""Tests for the Syncthing backend."""
|
|
146
|
+
|
|
147
|
+
def test_push_copies_to_outbox(self, agent_home: Path, tmp_path: Path):
|
|
148
|
+
"""Push should copy vault to the outbox directory."""
|
|
149
|
+
from skcapstone.sync.backends import SyncthingBackend
|
|
150
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
151
|
+
|
|
152
|
+
config = SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
|
|
153
|
+
backend = SyncthingBackend(config, agent_home)
|
|
154
|
+
|
|
155
|
+
vault_file = tmp_path / "vault-test.tar.gz"
|
|
156
|
+
vault_file.write_text("fake vault data")
|
|
157
|
+
manifest_file = tmp_path / "vault-test.manifest.json"
|
|
158
|
+
manifest_file.write_text('{"agent_name": "test"}')
|
|
159
|
+
|
|
160
|
+
result = backend.push(vault_file, manifest_file)
|
|
161
|
+
assert result is True
|
|
162
|
+
assert (backend.outbox / "vault-test.tar.gz").exists()
|
|
163
|
+
assert (backend.outbox / "vault-test.manifest.json").exists()
|
|
164
|
+
|
|
165
|
+
def test_pull_from_inbox(self, agent_home: Path, tmp_path: Path):
|
|
166
|
+
"""Pull should retrieve vault from inbox and move to archive."""
|
|
167
|
+
from skcapstone.sync.backends import SyncthingBackend
|
|
168
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
169
|
+
|
|
170
|
+
config = SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
|
|
171
|
+
backend = SyncthingBackend(config, agent_home)
|
|
172
|
+
|
|
173
|
+
inbox_file = backend.inbox / "vault-peer-20260223.tar.gz"
|
|
174
|
+
inbox_file.write_text("peer vault data")
|
|
175
|
+
|
|
176
|
+
result = backend.pull(tmp_path)
|
|
177
|
+
assert result is not None
|
|
178
|
+
assert result.name == "vault-peer-20260223.tar.gz"
|
|
179
|
+
assert not inbox_file.exists()
|
|
180
|
+
|
|
181
|
+
def test_pull_empty_inbox(self, agent_home: Path, tmp_path: Path):
|
|
182
|
+
"""Pull should return None when inbox is empty."""
|
|
183
|
+
from skcapstone.sync.backends import SyncthingBackend
|
|
184
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
185
|
+
|
|
186
|
+
config = SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
|
|
187
|
+
backend = SyncthingBackend(config, agent_home)
|
|
188
|
+
|
|
189
|
+
result = backend.pull(tmp_path)
|
|
190
|
+
assert result is None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TestLocalBackend:
|
|
194
|
+
"""Tests for the local filesystem backend."""
|
|
195
|
+
|
|
196
|
+
def test_push_and_pull_roundtrip(self, tmp_path: Path, agent_home: Path):
|
|
197
|
+
"""Local push then pull should retrieve the same vault."""
|
|
198
|
+
from skcapstone.sync.backends import LocalBackend
|
|
199
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
200
|
+
|
|
201
|
+
backup_dir = tmp_path / "backup"
|
|
202
|
+
backup_dir.mkdir()
|
|
203
|
+
config = SyncBackendConfig(
|
|
204
|
+
backend_type=SyncBackendType.LOCAL,
|
|
205
|
+
local_path=backup_dir,
|
|
206
|
+
)
|
|
207
|
+
backend = LocalBackend(config, agent_home)
|
|
208
|
+
|
|
209
|
+
vault_file = tmp_path / "vault-local-test.tar.gz"
|
|
210
|
+
vault_file.write_bytes(b"local vault content")
|
|
211
|
+
manifest_file = tmp_path / "vault-local-test.manifest.json"
|
|
212
|
+
manifest_file.write_text('{"agent_name": "local"}')
|
|
213
|
+
|
|
214
|
+
assert backend.push(vault_file, manifest_file) is True
|
|
215
|
+
|
|
216
|
+
pull_dir = tmp_path / "pulled"
|
|
217
|
+
pull_dir.mkdir()
|
|
218
|
+
result = backend.pull(pull_dir)
|
|
219
|
+
assert result is not None
|
|
220
|
+
assert result.read_bytes() == b"local vault content"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TestBackendFactory:
|
|
224
|
+
"""Tests for the create_backend factory function."""
|
|
225
|
+
|
|
226
|
+
def test_creates_syncthing(self, agent_home: Path):
|
|
227
|
+
from skcapstone.sync.backends import SyncthingBackend, create_backend
|
|
228
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
229
|
+
|
|
230
|
+
config = SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
|
|
231
|
+
backend = create_backend(config, agent_home)
|
|
232
|
+
assert isinstance(backend, SyncthingBackend)
|
|
233
|
+
|
|
234
|
+
def test_creates_local(self, agent_home: Path):
|
|
235
|
+
from skcapstone.sync.backends import LocalBackend, create_backend
|
|
236
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
237
|
+
|
|
238
|
+
config = SyncBackendConfig(backend_type=SyncBackendType.LOCAL)
|
|
239
|
+
backend = create_backend(config, agent_home)
|
|
240
|
+
assert isinstance(backend, LocalBackend)
|
|
241
|
+
|
|
242
|
+
def test_creates_github(self, agent_home: Path):
|
|
243
|
+
from skcapstone.sync.backends import GitBackend, create_backend
|
|
244
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
245
|
+
|
|
246
|
+
config = SyncBackendConfig(
|
|
247
|
+
backend_type=SyncBackendType.GITHUB, repo_url="https://github.com/test/repo"
|
|
248
|
+
)
|
|
249
|
+
backend = create_backend(config, agent_home)
|
|
250
|
+
assert isinstance(backend, GitBackend)
|
|
251
|
+
assert backend.name == "github"
|
|
252
|
+
|
|
253
|
+
def test_creates_forgejo(self, agent_home: Path):
|
|
254
|
+
from skcapstone.sync.backends import GitBackend, create_backend
|
|
255
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
256
|
+
|
|
257
|
+
config = SyncBackendConfig(
|
|
258
|
+
backend_type=SyncBackendType.FORGEJO, repo_url="https://forgejo.example/test"
|
|
259
|
+
)
|
|
260
|
+
backend = create_backend(config, agent_home)
|
|
261
|
+
assert isinstance(backend, GitBackend)
|
|
262
|
+
assert backend.name == "forgejo"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class TestSyncEngine:
|
|
266
|
+
"""Tests for the sync engine orchestration."""
|
|
267
|
+
|
|
268
|
+
def test_engine_initializes(self, agent_home: Path):
|
|
269
|
+
"""Engine should initialize with default config."""
|
|
270
|
+
from skcapstone.sync.engine import SyncEngine
|
|
271
|
+
|
|
272
|
+
engine = SyncEngine(agent_home)
|
|
273
|
+
assert engine.agent_home == agent_home
|
|
274
|
+
assert engine.config is not None
|
|
275
|
+
assert engine.state is not None
|
|
276
|
+
|
|
277
|
+
def test_add_backend(self, agent_home: Path):
|
|
278
|
+
"""Adding a backend should persist to config."""
|
|
279
|
+
from skcapstone.sync.engine import SyncEngine
|
|
280
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
281
|
+
|
|
282
|
+
engine = SyncEngine(agent_home)
|
|
283
|
+
config = SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
|
|
284
|
+
engine.add_backend(config)
|
|
285
|
+
|
|
286
|
+
assert len(engine.config.backends) == 1
|
|
287
|
+
assert engine.config.backends[0].backend_type == SyncBackendType.SYNCTHING
|
|
288
|
+
|
|
289
|
+
def test_status_returns_info(self, agent_home: Path):
|
|
290
|
+
"""Status should return backend and vault information."""
|
|
291
|
+
from skcapstone.sync.engine import SyncEngine
|
|
292
|
+
|
|
293
|
+
engine = SyncEngine(agent_home)
|
|
294
|
+
info = engine.status()
|
|
295
|
+
|
|
296
|
+
assert "state" in info
|
|
297
|
+
assert "backends" in info
|
|
298
|
+
assert "vaults" in info
|
|
299
|
+
assert "encrypt" in info
|
|
300
|
+
|
|
301
|
+
def test_push_with_syncthing_backend(self, agent_home: Path):
|
|
302
|
+
"""Push with syncthing backend should pack and deliver vault."""
|
|
303
|
+
from skcapstone.sync.engine import SyncEngine
|
|
304
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
305
|
+
|
|
306
|
+
engine = SyncEngine(agent_home)
|
|
307
|
+
engine.config.encrypt = False
|
|
308
|
+
engine.add_backend(
|
|
309
|
+
SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
results = engine.push(passphrase=None)
|
|
313
|
+
assert "syncthing" in results
|
|
314
|
+
assert results["syncthing"] is True
|
|
315
|
+
assert engine.state.push_count == 1
|
|
316
|
+
|
|
317
|
+
def test_push_pull_roundtrip(self, agent_home: Path, tmp_path: Path):
|
|
318
|
+
"""Full push then pull should restore state to a new location."""
|
|
319
|
+
from skcapstone.sync.engine import SyncEngine
|
|
320
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
321
|
+
|
|
322
|
+
engine = SyncEngine(agent_home)
|
|
323
|
+
engine.config.encrypt = False
|
|
324
|
+
backup_dir = tmp_path / "local-backup"
|
|
325
|
+
backup_dir.mkdir()
|
|
326
|
+
engine.add_backend(
|
|
327
|
+
SyncBackendConfig(
|
|
328
|
+
backend_type=SyncBackendType.LOCAL,
|
|
329
|
+
local_path=backup_dir,
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
push_results = engine.push(passphrase=None)
|
|
334
|
+
assert push_results.get("local") is True
|
|
335
|
+
|
|
336
|
+
restore_home = tmp_path / "restored"
|
|
337
|
+
restore_home.mkdir()
|
|
338
|
+
engine2 = SyncEngine(restore_home)
|
|
339
|
+
engine2.config.encrypt = False
|
|
340
|
+
engine2.add_backend(
|
|
341
|
+
SyncBackendConfig(
|
|
342
|
+
backend_type=SyncBackendType.LOCAL,
|
|
343
|
+
local_path=backup_dir,
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
result = engine2.pull(passphrase=None)
|
|
348
|
+
assert result is not None
|
|
349
|
+
|
|
350
|
+
def test_pull_no_backends(self, agent_home: Path):
|
|
351
|
+
"""Pull with no backends should return None gracefully."""
|
|
352
|
+
from skcapstone.sync.engine import SyncEngine
|
|
353
|
+
|
|
354
|
+
engine = SyncEngine(agent_home)
|
|
355
|
+
result = engine.pull(passphrase=None)
|
|
356
|
+
assert result is None
|
|
357
|
+
|
|
358
|
+
def test_pull_dry_run(self, agent_home: Path, tmp_path: Path):
|
|
359
|
+
"""Dry-run pull should download without extracting."""
|
|
360
|
+
from skcapstone.sync.engine import SyncEngine
|
|
361
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
362
|
+
|
|
363
|
+
engine = SyncEngine(agent_home)
|
|
364
|
+
engine.config.encrypt = False
|
|
365
|
+
backup_dir = tmp_path / "local-backup"
|
|
366
|
+
backup_dir.mkdir()
|
|
367
|
+
engine.add_backend(
|
|
368
|
+
SyncBackendConfig(
|
|
369
|
+
backend_type=SyncBackendType.LOCAL,
|
|
370
|
+
local_path=backup_dir,
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
engine.push(passphrase=None)
|
|
374
|
+
|
|
375
|
+
result = engine.pull(passphrase=None, dry_run=True)
|
|
376
|
+
assert result is not None
|
|
377
|
+
assert result.name.startswith("vault-")
|
|
378
|
+
|
|
379
|
+
def test_config_save_load_persistence(self, agent_home: Path):
|
|
380
|
+
"""Saved config should be loadable by a new engine instance."""
|
|
381
|
+
from skcapstone.sync.engine import SyncEngine
|
|
382
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
383
|
+
|
|
384
|
+
engine = SyncEngine(agent_home)
|
|
385
|
+
engine.add_backend(
|
|
386
|
+
SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
|
|
387
|
+
)
|
|
388
|
+
engine.add_backend(
|
|
389
|
+
SyncBackendConfig(
|
|
390
|
+
backend_type=SyncBackendType.LOCAL,
|
|
391
|
+
local_path=agent_home / "sync" / "local-backup",
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
engine2 = SyncEngine(agent_home)
|
|
396
|
+
assert len(engine2.config.backends) == 2
|
|
397
|
+
|
|
398
|
+
def test_state_persists_across_operations(self, agent_home: Path):
|
|
399
|
+
"""Push count and timestamps should persist to disk after push."""
|
|
400
|
+
from skcapstone.sync.engine import SyncEngine
|
|
401
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
402
|
+
|
|
403
|
+
engine = SyncEngine(agent_home)
|
|
404
|
+
engine.config.encrypt = False
|
|
405
|
+
engine.add_backend(
|
|
406
|
+
SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
|
|
407
|
+
)
|
|
408
|
+
engine.push(passphrase=None)
|
|
409
|
+
assert engine.state.push_count == 1
|
|
410
|
+
assert engine.state.last_push is not None
|
|
411
|
+
assert engine.state.last_push_backend == "syncthing"
|
|
412
|
+
|
|
413
|
+
def test_backend_filter(self, agent_home: Path, tmp_path: Path):
|
|
414
|
+
"""Push with backend_filter should only push to that backend."""
|
|
415
|
+
from skcapstone.sync.engine import SyncEngine
|
|
416
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
417
|
+
|
|
418
|
+
engine = SyncEngine(agent_home)
|
|
419
|
+
engine.config.encrypt = False
|
|
420
|
+
engine.add_backend(
|
|
421
|
+
SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
|
|
422
|
+
)
|
|
423
|
+
backup_dir = tmp_path / "local-backup"
|
|
424
|
+
backup_dir.mkdir()
|
|
425
|
+
engine.add_backend(
|
|
426
|
+
SyncBackendConfig(
|
|
427
|
+
backend_type=SyncBackendType.LOCAL,
|
|
428
|
+
local_path=backup_dir,
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
results = engine.push(passphrase=None, backend_filter="local")
|
|
433
|
+
assert "local" in results
|
|
434
|
+
assert "syncthing" not in results
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class TestVaultManifestModel:
|
|
438
|
+
"""Tests for the VaultManifest Pydantic model."""
|
|
439
|
+
|
|
440
|
+
def test_manifest_serialization(self):
|
|
441
|
+
"""VaultManifest should serialize to JSON and back."""
|
|
442
|
+
from skcapstone.sync.models import VaultManifest
|
|
443
|
+
|
|
444
|
+
manifest = VaultManifest(
|
|
445
|
+
agent_name="TestAgent",
|
|
446
|
+
source_host="test-host",
|
|
447
|
+
created_at=datetime(2026, 2, 23, tzinfo=timezone.utc),
|
|
448
|
+
pillars_included=["identity", "memory", "trust"],
|
|
449
|
+
encrypted=True,
|
|
450
|
+
)
|
|
451
|
+
json_str = manifest.model_dump_json()
|
|
452
|
+
restored = VaultManifest.model_validate_json(json_str)
|
|
453
|
+
assert restored.agent_name == "TestAgent"
|
|
454
|
+
assert restored.pillars_included == ["identity", "memory", "trust"]
|
|
455
|
+
assert restored.encrypted is True
|
|
456
|
+
|
|
457
|
+
def test_manifest_defaults(self):
|
|
458
|
+
"""VaultManifest should have sensible defaults."""
|
|
459
|
+
from skcapstone.sync.models import VaultManifest
|
|
460
|
+
|
|
461
|
+
manifest = VaultManifest(
|
|
462
|
+
agent_name="Test",
|
|
463
|
+
source_host="host",
|
|
464
|
+
created_at=datetime.now(timezone.utc),
|
|
465
|
+
)
|
|
466
|
+
assert manifest.schema_version == "1.0"
|
|
467
|
+
assert manifest.encrypted is True
|
|
468
|
+
assert manifest.pillars_included == []
|
|
469
|
+
assert manifest.fingerprint is None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class TestSyncBackendConfigModel:
|
|
473
|
+
"""Tests for the SyncBackendConfig model."""
|
|
474
|
+
|
|
475
|
+
def test_syncthing_config(self):
|
|
476
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
477
|
+
|
|
478
|
+
config = SyncBackendConfig(
|
|
479
|
+
backend_type=SyncBackendType.SYNCTHING,
|
|
480
|
+
syncthing_folder_id="skcapstone-sync",
|
|
481
|
+
)
|
|
482
|
+
assert config.backend_type == SyncBackendType.SYNCTHING
|
|
483
|
+
assert config.enabled is True
|
|
484
|
+
|
|
485
|
+
def test_git_config(self):
|
|
486
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
487
|
+
|
|
488
|
+
config = SyncBackendConfig(
|
|
489
|
+
backend_type=SyncBackendType.GITHUB,
|
|
490
|
+
repo_url="https://github.com/test/repo",
|
|
491
|
+
branch="main",
|
|
492
|
+
)
|
|
493
|
+
assert config.repo_url == "https://github.com/test/repo"
|
|
494
|
+
assert config.branch == "main"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class TestUnsupportedBackend:
|
|
498
|
+
"""Edge case: unsupported backend type."""
|
|
499
|
+
|
|
500
|
+
def test_factory_rejects_gdrive(self, agent_home: Path):
|
|
501
|
+
"""GDrive backend should raise ValueError (not implemented)."""
|
|
502
|
+
from skcapstone.sync.backends import create_backend
|
|
503
|
+
from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
|
|
504
|
+
|
|
505
|
+
config = SyncBackendConfig(backend_type=SyncBackendType.GDRIVE)
|
|
506
|
+
with pytest.raises(ValueError, match="Unsupported"):
|
|
507
|
+
create_backend(config, agent_home)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Tests for the Syncthing setup skill."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from skcapstone.skills.syncthing_setup import (
|
|
7
|
+
detect_syncthing,
|
|
8
|
+
get_install_instructions,
|
|
9
|
+
ensure_shared_folder,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestDetectSyncthing:
|
|
14
|
+
"""Tests for detect_syncthing."""
|
|
15
|
+
|
|
16
|
+
@patch("shutil.which", return_value="/usr/bin/syncthing")
|
|
17
|
+
def test_found(self, mock_which):
|
|
18
|
+
"""Returns path when syncthing is installed."""
|
|
19
|
+
assert detect_syncthing() == "/usr/bin/syncthing"
|
|
20
|
+
|
|
21
|
+
@patch("shutil.which", return_value=None)
|
|
22
|
+
def test_not_found(self, mock_which):
|
|
23
|
+
"""Returns None when syncthing is not installed."""
|
|
24
|
+
assert detect_syncthing() is None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestGetInstallInstructions:
|
|
28
|
+
"""Tests for get_install_instructions."""
|
|
29
|
+
|
|
30
|
+
@patch("platform.system", return_value="Linux")
|
|
31
|
+
def test_returns_string(self, mock_sys):
|
|
32
|
+
"""Always returns a non-empty string."""
|
|
33
|
+
instructions = get_install_instructions()
|
|
34
|
+
assert isinstance(instructions, str)
|
|
35
|
+
assert len(instructions) > 0
|
|
36
|
+
|
|
37
|
+
@patch("platform.system", return_value="Darwin")
|
|
38
|
+
def test_macos_mentions_brew(self, mock_sys):
|
|
39
|
+
"""macOS instructions mention brew."""
|
|
40
|
+
instructions = get_install_instructions()
|
|
41
|
+
assert "brew" in instructions
|
|
42
|
+
|
|
43
|
+
@patch("platform.system", return_value="Windows")
|
|
44
|
+
def test_windows_mentions_winget(self, mock_sys):
|
|
45
|
+
"""Windows instructions mention winget."""
|
|
46
|
+
instructions = get_install_instructions()
|
|
47
|
+
assert "winget" in instructions
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestEnsureSharedFolder:
|
|
51
|
+
"""Tests for ensure_shared_folder."""
|
|
52
|
+
|
|
53
|
+
def test_creates_directories(self, tmp_path, monkeypatch):
|
|
54
|
+
"""Creates outbox, inbox, archive subdirectories."""
|
|
55
|
+
monkeypatch.setattr(
|
|
56
|
+
"skcapstone.skills.syncthing_setup.SYNC_DIR",
|
|
57
|
+
tmp_path / "sync",
|
|
58
|
+
)
|
|
59
|
+
from skcapstone.skills.syncthing_setup import ensure_shared_folder
|
|
60
|
+
|
|
61
|
+
result = ensure_shared_folder()
|
|
62
|
+
assert (result / "outbox").exists()
|
|
63
|
+
assert (result / "inbox").exists()
|
|
64
|
+
assert (result / "archive").exists()
|
|
65
|
+
|
|
66
|
+
def test_idempotent(self, tmp_path, monkeypatch):
|
|
67
|
+
"""Calling twice doesn't fail."""
|
|
68
|
+
monkeypatch.setattr(
|
|
69
|
+
"skcapstone.skills.syncthing_setup.SYNC_DIR",
|
|
70
|
+
tmp_path / "sync",
|
|
71
|
+
)
|
|
72
|
+
from skcapstone.skills.syncthing_setup import ensure_shared_folder
|
|
73
|
+
|
|
74
|
+
ensure_shared_folder()
|
|
75
|
+
ensure_shared_folder()
|
|
76
|
+
assert (tmp_path / "sync" / "outbox").exists()
|