@smilintux/skcapstone 0.6.1 → 0.6.3

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 (71) hide show
  1. package/.github/workflows/publish.yml +1 -1
  2. package/CLAUDE.md +17 -0
  3. package/docs/CUSTOM_AGENT.md +40 -28
  4. package/docs/SOUL_SWAPPER.md +5 -5
  5. package/docs/hammertime-audit.md +402 -0
  6. package/openclaw-plugin/src/index.ts +2 -1
  7. package/package.json +1 -1
  8. package/pyproject.toml +1 -1
  9. package/scripts/archive-sessions.sh +7 -0
  10. package/scripts/install.sh +126 -1
  11. package/scripts/model-fallback-monitor.sh +4 -2
  12. package/scripts/refresh-anthropic-token.sh +9 -3
  13. package/scripts/release.sh +98 -0
  14. package/scripts/session-to-memory.py +219 -0
  15. package/scripts/sk-agent-picker.sh +237 -0
  16. package/scripts/telegram-catchup-all.sh +2 -1
  17. package/scripts/watch-anthropic-token.sh +12 -17
  18. package/src/skcapstone/__init__.py +34 -2
  19. package/src/skcapstone/cli/__init__.py +3 -1
  20. package/src/skcapstone/cli/_common.py +1 -0
  21. package/src/skcapstone/cli/context_cmd.py +16 -4
  22. package/src/skcapstone/cli/daemon.py +2 -1
  23. package/src/skcapstone/cli/joule_cmd.py +7 -3
  24. package/src/skcapstone/cli/memory.py +4 -2
  25. package/src/skcapstone/cli/register_cmd.py +19 -3
  26. package/src/skcapstone/cli/session.py +25 -0
  27. package/src/skcapstone/cli/setup.py +96 -30
  28. package/src/skcapstone/cli/soul.py +3 -3
  29. package/src/skcapstone/context_loader.py +9 -0
  30. package/src/skcapstone/coordination.py +9 -2
  31. package/src/skcapstone/daemon.py +22 -12
  32. package/src/skcapstone/defaults/claude/CLAUDE.md +67 -0
  33. package/src/skcapstone/defaults/claude/settings.json +74 -0
  34. package/src/skcapstone/defaults/lumina/config/skgraph.yaml +55 -10
  35. package/src/skcapstone/defaults/lumina/config/skmemory.yaml +79 -13
  36. package/src/skcapstone/defaults/lumina/config/skvector.yaml +60 -9
  37. package/src/skcapstone/defaults/unhinged.json +13 -0
  38. package/src/skcapstone/discovery.py +5 -5
  39. package/src/skcapstone/doctor.py +4 -2
  40. package/src/skcapstone/dreaming.py +3 -1
  41. package/src/skcapstone/fuse_mount.py +3 -1
  42. package/src/skcapstone/housekeeping.py +3 -3
  43. package/src/skcapstone/install_wizard.py +131 -0
  44. package/src/skcapstone/mcp_launcher.py +14 -1
  45. package/src/skcapstone/mcp_server.py +6 -21
  46. package/src/skcapstone/mcp_tools/notification_tools.py +3 -1
  47. package/src/skcapstone/memory_engine.py +10 -3
  48. package/src/skcapstone/migrate_multi_agent.py +7 -6
  49. package/src/skcapstone/notifications.py +6 -2
  50. package/src/skcapstone/onboard.py +19 -8
  51. package/src/skcapstone/operator_link.py +164 -0
  52. package/src/skcapstone/pillars/consciousness.py +2 -1
  53. package/src/skcapstone/pillars/identity.py +51 -7
  54. package/src/skcapstone/pillars/memory.py +9 -3
  55. package/src/skcapstone/runtime.py +13 -3
  56. package/src/skcapstone/service_health.py +23 -10
  57. package/src/skcapstone/session_briefing.py +108 -0
  58. package/src/skcapstone/trust_graph.py +40 -5
  59. package/src/skcapstone/unified_search.py +11 -2
  60. package/systemd/skcapstone.service +4 -6
  61. package/systemd/skcapstone@.service +7 -8
  62. package/systemd/skcomm-heartbeat.service +5 -2
  63. package/tests/conftest.py +21 -0
  64. package/tests/test_agent_home_scaffold.py +34 -0
  65. package/tests/test_backup.py +2 -1
  66. package/tests/test_mcp_server.py +78 -33
  67. package/tests/test_multi_agent.py +31 -29
  68. package/tests/test_operator_link.py +78 -0
  69. package/tests/test_runtime.py +21 -0
  70. package/tests/test_session_briefing.py +130 -0
  71. package/tests/test_trust_graph.py +18 -0
@@ -9,16 +9,22 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import logging
12
- import subprocess
13
12
  from datetime import datetime, timezone
13
+ from shutil import copyfile
14
14
  from pathlib import Path
15
15
  from typing import Optional
16
16
 
17
17
  from ..models import IdentityState, PillarStatus
18
+ from ..operator_link import create_operator_attestation
18
19
 
19
20
  logger = logging.getLogger("skcapstone.identity")
20
21
 
21
22
 
23
+ def _capauth_home(home: Path) -> Path:
24
+ """Return the agent-local CapAuth home for an SKCapstone agent."""
25
+ return home / "capauth"
26
+
27
+
22
28
  def generate_identity(
23
29
  home: Path,
24
30
  name: str,
@@ -47,10 +53,14 @@ def generate_identity(
47
53
  status=PillarStatus.DEGRADED,
48
54
  )
49
55
 
50
- capauth_state = _try_init_capauth(name, state.email, identity_dir)
56
+ capauth_home = _capauth_home(home)
57
+ capauth_state = _try_init_capauth(name, state.email, identity_dir, capauth_home)
51
58
  if capauth_state is not None:
52
59
  state.fingerprint = capauth_state.fingerprint
53
60
  state.key_path = capauth_state.key_path
61
+ state.name = capauth_state.name
62
+ state.email = capauth_state.email
63
+ state.created_at = capauth_state.created_at
54
64
  state.status = PillarStatus.ACTIVE
55
65
  else:
56
66
  state.fingerprint = _generate_placeholder_fingerprint(name)
@@ -63,18 +73,39 @@ def generate_identity(
63
73
  "created_at": state.created_at.isoformat() if state.created_at else None,
64
74
  "capauth_managed": state.status == PillarStatus.ACTIVE,
65
75
  }
76
+ if state.key_path is not None:
77
+ identity_manifest["public_key_path"] = str(state.key_path)
78
+ if state.status == PillarStatus.ACTIVE:
79
+ identity_manifest["capauth_home"] = str(capauth_home)
80
+
81
+ attestation = create_operator_attestation(
82
+ agent_name=state.name or name,
83
+ agent_fingerprint=state.fingerprint or "",
84
+ agent_public_key_path=state.key_path or (capauth_home / "identity" / "public.asc"),
85
+ output_dir=identity_dir,
86
+ )
87
+ if attestation is not None:
88
+ payload = attestation.get("payload", {})
89
+ identity_manifest["operator_attestation_path"] = str(
90
+ identity_dir / "operator-attestation.json"
91
+ )
92
+ identity_manifest["operator_attested_by"] = payload.get("operator_fingerprint")
93
+
66
94
  (identity_dir / "identity.json").write_text(json.dumps(identity_manifest, indent=2), encoding="utf-8")
67
95
 
68
96
  return state
69
97
 
70
98
 
71
99
  def _try_init_capauth(
72
- name: str, email: str, identity_dir: Path
100
+ name: str,
101
+ email: str,
102
+ identity_dir: Path,
103
+ capauth_home: Path,
73
104
  ) -> Optional[IdentityState]:
74
105
  """Try to create or load a real CapAuth identity.
75
106
 
76
107
  Attempts (in order):
77
- 1. Load an existing CapAuth profile from ~/.capauth/
108
+ 1. Load an existing CapAuth profile from the agent-local CapAuth home
78
109
  2. Create a new profile via capauth.profile.init_profile()
79
110
  3. Fall back to legacy capauth.keys.generate_keypair()
80
111
 
@@ -90,12 +121,17 @@ def _try_init_capauth(
90
121
  try:
91
122
  from capauth.profile import load_profile # type: ignore[import-untyped]
92
123
 
93
- profile = load_profile()
124
+ profile = load_profile(base_dir=capauth_home)
125
+ key_path = Path(profile.key_info.public_key_path)
126
+ legacy_key_path = identity_dir / "agent.pub"
127
+ if key_path.exists() and not legacy_key_path.exists():
128
+ copyfile(key_path, legacy_key_path)
94
129
  return IdentityState(
95
130
  fingerprint=profile.key_info.fingerprint,
96
- key_path=Path(profile.key_info.public_key_path),
131
+ key_path=key_path,
97
132
  name=profile.entity.name,
98
133
  email=profile.entity.email,
134
+ created_at=profile.key_info.created,
99
135
  status=PillarStatus.ACTIVE,
100
136
  )
101
137
  except ImportError:
@@ -105,18 +141,26 @@ def _try_init_capauth(
105
141
 
106
142
  # No existing profile — try creating one
107
143
  try:
144
+ from capauth.models import EntityType # type: ignore[import-untyped]
108
145
  from capauth.profile import init_profile # type: ignore[import-untyped]
109
146
 
110
147
  profile = init_profile(
111
148
  name=name,
112
149
  email=email,
113
150
  passphrase="",
151
+ entity_type=EntityType.AI,
152
+ base_dir=capauth_home,
114
153
  )
154
+ key_path = Path(profile.key_info.public_key_path)
155
+ legacy_key_path = identity_dir / "agent.pub"
156
+ if key_path.exists():
157
+ copyfile(key_path, legacy_key_path)
115
158
  return IdentityState(
116
159
  fingerprint=profile.key_info.fingerprint,
117
- key_path=Path(profile.key_info.public_key_path),
160
+ key_path=key_path,
118
161
  name=profile.entity.name,
119
162
  email=profile.entity.email,
163
+ created_at=profile.key_info.created,
120
164
  status=PillarStatus.ACTIVE,
121
165
  )
122
166
  except Exception as exc:
@@ -11,9 +11,11 @@ store/search/recall/list/gc capabilities. The optional external
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import os
14
15
  from pathlib import Path
15
16
  from typing import Optional
16
17
 
18
+ from .. import active_agent_name
17
19
  from ..models import MemoryLayer, MemoryState, PillarStatus
18
20
 
19
21
 
@@ -31,9 +33,13 @@ def initialize_memory(home: Path, memory_home: Optional[Path] = None) -> MemoryS
31
33
  Returns:
32
34
  MemoryState after initialization.
33
35
  """
34
- # Use agent-specific memory directory
35
- agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
36
- memory_dir = home / "agents" / agent_name / "memory"
36
+ agent_name = os.environ.get("SKCAPSTONE_AGENT") or active_agent_name()
37
+ if home.parent.name == "agents":
38
+ memory_dir = home / "memory"
39
+ elif agent_name:
40
+ memory_dir = home / "agents" / agent_name / "memory"
41
+ else:
42
+ memory_dir = home / "memory"
37
43
  memory_dir.mkdir(parents=True, exist_ok=True)
38
44
 
39
45
  for layer in MemoryLayer:
@@ -94,10 +94,14 @@ class AgentRuntime:
94
94
  logger.info("Awakening agent from %s (shared: %s)", self.home, self.shared_root)
95
95
 
96
96
  manifest_file = self.home / "manifest.json"
97
+ manifest_name_loaded = False
97
98
  if manifest_file.exists():
98
99
  try:
99
100
  data = json.loads(manifest_file.read_text(encoding="utf-8"))
100
- self.manifest.name = data.get("name", self.manifest.name)
101
+ manifest_name = data.get("name")
102
+ if manifest_name:
103
+ self.manifest.name = manifest_name
104
+ manifest_name_loaded = True
101
105
  if data.get("created_at"):
102
106
  self.manifest.created_at = datetime.fromisoformat(data["created_at"])
103
107
  connectors_data = data.get("connectors", [])
@@ -105,8 +109,6 @@ class AgentRuntime:
105
109
  except (json.JSONDecodeError, ValueError) as exc:
106
110
  logger.warning("Failed to load manifest: %s", exc)
107
111
 
108
- self.manifest.name = self.config.agent_name
109
-
110
112
  # Discover pillars from per-agent home
111
113
  pillars = discover_all(self.home, shared_root=self.shared_root)
112
114
  self.manifest.identity = pillars["identity"]
@@ -117,6 +119,14 @@ class AgentRuntime:
117
119
  self.manifest.sync = pillars["sync"]
118
120
  self.manifest.skills = pillars["skills"]
119
121
 
122
+ if (
123
+ self.manifest.identity.name
124
+ and self.manifest.identity.status == PillarStatus.ACTIVE
125
+ ):
126
+ self.manifest.name = self.manifest.identity.name
127
+ elif not manifest_name_loaded and self.config.agent_name:
128
+ self.manifest.name = self.config.agent_name
129
+
120
130
  self.manifest.last_awakened = datetime.now(timezone.utc)
121
131
  self._awakened = True
122
132
 
@@ -32,6 +32,10 @@ logger = logging.getLogger("skcapstone.service_health")
32
32
  # Default timeout per service check (seconds).
33
33
  CHECK_TIMEOUT = 3
34
34
 
35
+ # Hostname tag for multi-machine dedup — prevents Syncthing conflicts when
36
+ # multiple daemons write to the same ITIL incident files.
37
+ _HOSTNAME = socket.gethostname()
38
+
35
39
 
36
40
  # ---------------------------------------------------------------------------
37
41
  # Individual service checks
@@ -218,18 +222,23 @@ def _create_incident_for_down_service(service_result: dict[str, Any]) -> None:
218
222
  from .itil import ITILManager
219
223
 
220
224
  svc_name = service_result["name"]
225
+ error_info = service_result.get("error") or "unreachable"
221
226
  mgr = ITILManager(os.path.expanduser(SHARED_ROOT))
222
227
 
223
228
  # Dedup: skip if there's already an open incident for this service
224
229
  existing = mgr.find_open_incident_for_service(svc_name)
225
230
  if existing:
226
- logger.debug(
227
- "Skipping incident creation for %s open incident %s exists",
228
- svc_name, existing.id,
229
- )
231
+ # Only add a "still down" note if this host hasn't noted it recently
232
+ last_notes = [e.get("note", "") for e in (existing.timeline or [])[-3:]]
233
+ host_tag = f"[{_HOSTNAME}]"
234
+ if any(host_tag in n and "still down" in n for n in last_notes):
235
+ logger.debug("Skipping duplicate down note for %s from %s", svc_name, _HOSTNAME)
236
+ else:
237
+ mgr.update_incident(
238
+ existing.id, "service_health",
239
+ note=f"[{_HOSTNAME}] Service {svc_name} still down: {error_info}",
240
+ )
230
241
  return
231
-
232
- error_info = service_result.get("error") or "unreachable"
233
242
  mgr.create_incident(
234
243
  title=f"{svc_name} down",
235
244
  severity="sev3",
@@ -267,10 +276,14 @@ def _auto_resolve_recovered_service(service_result: dict[str, Any]) -> None:
267
276
  logger.info("Auto-resolved sev4 incident %s for recovered service %s",
268
277
  existing.id, svc_name)
269
278
  else:
270
- mgr.update_incident(
271
- existing.id, "service_health",
272
- note=f"Service {svc_name} appears to be back up",
273
- )
279
+ # Skip if this host already noted recovery recently
280
+ last_notes = [e.get("note", "") for e in (existing.timeline or [])[-3:]]
281
+ host_tag = f"[{_HOSTNAME}]"
282
+ if not any(host_tag in n and "back up" in n for n in last_notes):
283
+ mgr.update_incident(
284
+ existing.id, "service_health",
285
+ note=f"[{_HOSTNAME}] Service {svc_name} appears to be back up",
286
+ )
274
287
  except Exception as exc:
275
288
  logger.debug("Failed to auto-resolve incident for %s: %s",
276
289
  service_result.get("name"), exc)
@@ -0,0 +1,108 @@
1
+ """Session briefing helpers for SKCapstone startup flows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .context_loader import format_text, gather_context
14
+
15
+
16
+ DEFAULT_HAMMERTIME_ROOT = Path("/mnt/cloud/onedrive/projects/DAVE AI/hammerTime")
17
+
18
+
19
+ def _resolve_hammertime_root() -> Path:
20
+ """Resolve the HammerTime workspace root."""
21
+ return Path(os.environ.get("HAMMERTIME_ROOT", DEFAULT_HAMMERTIME_ROOT)).expanduser()
22
+
23
+
24
+ def load_hammertime_briefing(
25
+ *,
26
+ python_bin: str | None = None,
27
+ root: Path | None = None,
28
+ ) -> dict[str, Any] | None:
29
+ """Load the HammerTime case briefing if the repo is available."""
30
+ if os.environ.get("SK_INCLUDE_HAMMERTIME_BRIEFING", "1") == "0":
31
+ return None
32
+
33
+ hammer_root = root or _resolve_hammertime_root()
34
+ script = hammer_root / "scripts" / "case-briefing.py"
35
+ if not script.exists():
36
+ return None
37
+
38
+ try:
39
+ completed = subprocess.run(
40
+ [python_bin or sys.executable, str(script)],
41
+ check=True,
42
+ capture_output=True,
43
+ text=True,
44
+ )
45
+ except (OSError, subprocess.CalledProcessError):
46
+ return None
47
+
48
+ try:
49
+ payload = json.loads(completed.stdout)
50
+ except json.JSONDecodeError:
51
+ return None
52
+ return payload if isinstance(payload, dict) else None
53
+
54
+
55
+ def build_session_briefing(
56
+ home: Path,
57
+ *,
58
+ memory_limit: int = 10,
59
+ python_bin: str | None = None,
60
+ ) -> dict[str, Any]:
61
+ """Build a native session briefing payload."""
62
+ return {
63
+ "generated_at": datetime.now(timezone.utc).isoformat(),
64
+ "agent_home": str(home),
65
+ "skcapstone_context": gather_context(home, memory_limit=memory_limit),
66
+ "hammertime_briefing": load_hammertime_briefing(python_bin=python_bin),
67
+ }
68
+
69
+
70
+ def format_session_briefing_text(payload: dict[str, Any]) -> str:
71
+ """Render a human-readable session briefing."""
72
+ lines = [
73
+ "# SKCapstone Session Briefing",
74
+ "",
75
+ f"generated_at={payload.get('generated_at')}",
76
+ f"agent_home={payload.get('agent_home')}",
77
+ "",
78
+ "## skcapstone context",
79
+ format_text(payload["skcapstone_context"]).rstrip(),
80
+ ]
81
+
82
+ briefing = payload.get("hammertime_briefing")
83
+ if briefing:
84
+ top = briefing.get("top_priority") or {}
85
+ lines.extend(
86
+ [
87
+ "",
88
+ "## hammertime briefing",
89
+ f"- alert_count: {briefing.get('alert_count', 0)}",
90
+ f"- queue_size: {briefing.get('summary', {}).get('queue_size', 0)}",
91
+ ]
92
+ )
93
+ if top:
94
+ lines.extend(
95
+ [
96
+ f"- do_this_now_incident: {top.get('incident_id')} ({top.get('problem_slug')})",
97
+ f"- do_this_now_action: {top.get('action')}",
98
+ f"- do_this_now_status: {top.get('status')}",
99
+ ]
100
+ )
101
+ for item in (briefing.get("focus_items") or [])[:3]:
102
+ lines.append(
103
+ f"- focus: {item.get('incident_id')} -> {item.get('action')} [{item.get('status')}]"
104
+ )
105
+ else:
106
+ lines.extend(["", "## hammertime briefing", "- unavailable"])
107
+
108
+ return "\n".join(lines).rstrip() + "\n"
@@ -117,6 +117,8 @@ def build_trust_graph(home: Path) -> TrustGraph:
117
117
 
118
118
  def _add_self_node(home: Path, graph: TrustGraph) -> None:
119
119
  """Add the local agent as the central node."""
120
+ manifest_data: dict[str, Any] = {}
121
+
120
122
  identity_file = home / "identity" / "identity.json"
121
123
  if identity_file.exists():
122
124
  try:
@@ -130,20 +132,53 @@ def _add_self_node(home: Path, graph: TrustGraph) -> None:
130
132
  fingerprint=data.get("fingerprint"),
131
133
  metadata={"capauth_managed": data.get("capauth_managed", False)},
132
134
  ))
133
- return
134
135
  except (json.JSONDecodeError, OSError):
135
136
  pass
136
137
 
137
138
  manifest = home / "manifest.json"
138
139
  if manifest.exists():
139
140
  try:
140
- data = json.loads(manifest.read_text(encoding="utf-8"))
141
- name = data.get("name", "self")
142
- graph.agent_name = name
143
- graph.add_node(TrustNode(id=name, label=name, node_type="agent"))
141
+ manifest_data = json.loads(manifest.read_text(encoding="utf-8"))
142
+ if not graph.nodes:
143
+ name = manifest_data.get("name", "self")
144
+ graph.agent_name = name
145
+ graph.add_node(TrustNode(id=name, label=name, node_type="agent"))
144
146
  except (json.JSONDecodeError, OSError):
145
147
  pass
146
148
 
149
+ _add_operator_edge(manifest_data, graph)
150
+
151
+
152
+ def _add_operator_edge(manifest_data: dict[str, Any], graph: TrustGraph) -> None:
153
+ """Add an explicit human-operator relationship from manifest metadata."""
154
+ operator = manifest_data.get("operator")
155
+ if not isinstance(operator, dict):
156
+ return
157
+
158
+ name = str(operator.get("name", "")).strip()
159
+ if not name or not graph.agent_name:
160
+ return
161
+
162
+ node_id = str(operator.get("fingerprint", "")).strip() or f"operator:{name}"
163
+ graph.add_node(TrustNode(
164
+ id=node_id,
165
+ label=name,
166
+ node_type="peer",
167
+ fingerprint=str(operator.get("fingerprint", "")).strip() or None,
168
+ metadata={
169
+ "relationship": operator.get("relationship", "human-operator"),
170
+ "entity_type": operator.get("entity_type", "human"),
171
+ "source": operator.get("source", "manifest"),
172
+ },
173
+ ))
174
+ graph.add_edge(TrustEdge(
175
+ source=graph.agent_name,
176
+ target=node_id,
177
+ edge_type="operator",
178
+ label=operator.get("relationship", "human-operator"),
179
+ strength=1.0,
180
+ ))
181
+
147
182
 
148
183
  def _add_token_edges(home: Path, graph: TrustGraph) -> None:
149
184
  """Add edges from capability token issuance (issuer trusts subject)."""
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import json
17
17
  import logging
18
+ import os
18
19
  import re
19
20
  from dataclasses import dataclass, field
20
21
  from datetime import datetime, timezone
@@ -140,8 +141,16 @@ def _search_memories(
140
141
  List of SearchResult objects from the memory store.
141
142
  """
142
143
  results: list[SearchResult] = []
143
- agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
144
- mem_dir = home / "agents" / agent_name / "memory"
144
+ from . import active_agent_name
145
+
146
+ agent_name = os.environ.get("SKCAPSTONE_AGENT") or active_agent_name()
147
+ if home.parent.name == "agents":
148
+ # home is already an agent-specific dir (e.g. ~/.skcapstone/agents/lumina)
149
+ mem_dir = home / "memory"
150
+ elif agent_name:
151
+ mem_dir = home / "agents" / agent_name / "memory"
152
+ else:
153
+ mem_dir = home / "memory"
145
154
  if not mem_dir.exists():
146
155
  return results
147
156
 
@@ -16,20 +16,18 @@ MemoryMax=4G
16
16
  # Keep Ollama models warm for 5 minutes between requests
17
17
  Environment=PYTHONUNBUFFERED=1
18
18
  Environment=OLLAMA_KEEP_ALIVE=5m
19
+ Environment=SKAGENT=lumina
19
20
  Environment=SKCAPSTONE_AGENT=lumina
21
+ Environment=SKMEMORY_AGENT=lumina
20
22
  # Journal logging
21
23
  StandardOutput=journal
22
24
  StandardError=journal
23
25
  SyslogIdentifier=skcapstone
24
26
 
25
- # Security hardening
27
+ # Security hardening (relaxed — ProtectHome=read-only breaks if any
28
+ # ReadWritePaths dir is missing on the host)
26
29
  NoNewPrivileges=true
27
- ProtectSystem=strict
28
- ProtectHome=read-only
29
- ReadWritePaths=%h/.skcapstone %h/.skenv %h/.capauth %h/.cloud9 %h/.skcomm %h/.skchat
30
30
  PrivateTmp=true
31
- ProtectKernelTunables=true
32
- ProtectControlGroups=true
33
31
 
34
32
  [Install]
35
33
  WantedBy=default.target
@@ -20,8 +20,8 @@ Wants=network-online.target
20
20
 
21
21
  [Service]
22
22
  Type=notify
23
- ExecStart=skcapstone daemon start --agent %i --foreground
24
- ExecStop=skcapstone daemon stop --agent %i
23
+ ExecStart=%h/.skenv/bin/skcapstone daemon start --agent %i --foreground
24
+ ExecStop=%h/.skenv/bin/skcapstone daemon stop --agent %i
25
25
  ExecReload=/bin/kill -HUP $MAINPID
26
26
  Restart=on-failure
27
27
  RestartSec=10
@@ -31,20 +31,19 @@ WatchdogSec=300
31
31
  # resolve the correct per-agent home even before the CLI flag is processed.
32
32
  Environment=PYTHONUNBUFFERED=1
33
33
  Environment=OLLAMA_KEEP_ALIVE=5m
34
+ Environment=SKAGENT=%i
34
35
  Environment=SKCAPSTONE_AGENT=%i
36
+ Environment=SKMEMORY_AGENT=%i
37
+ Environment=PATH=%h/.skenv/bin:/usr/local/bin:/usr/bin:/bin
35
38
  # Journal logging — logs appear under skcapstone@<instance>
36
39
  StandardOutput=journal
37
40
  StandardError=journal
38
41
  SyslogIdentifier=skcapstone@%i
39
42
 
40
- # Security hardening (same as single-agent unit)
43
+ # Security hardening (relaxed ProtectHome=read-only breaks if any
44
+ # ReadWritePaths dir is missing on the host)
41
45
  NoNewPrivileges=true
42
- ProtectSystem=strict
43
- ProtectHome=read-only
44
- ReadWritePaths=%h/.skcapstone %h/.skmemory %h/.capauth %h/.cloud9 %h/.skcomm %h/.skchat
45
46
  PrivateTmp=true
46
- ProtectKernelTunables=true
47
- ProtectControlGroups=true
48
47
 
49
48
  [Install]
50
49
  WantedBy=default.target
@@ -5,8 +5,8 @@ After=network-online.target
5
5
 
6
6
  [Service]
7
7
  Type=oneshot
8
- ExecStart=skcomm heartbeat --no-emit
9
- ExecStart=skcomm heartbeat
8
+ ExecStart=%h/.skenv/bin/skcomm heartbeat --no-emit
9
+ ExecStart=%h/.skenv/bin/skcomm heartbeat
10
10
  Nice=19
11
11
 
12
12
  NoNewPrivileges=true
@@ -16,3 +16,6 @@ ReadWritePaths=%h/.skcapstone %h/.skcomm
16
16
  PrivateTmp=true
17
17
 
18
18
  Environment=PYTHONUNBUFFERED=1
19
+ Environment=SKAGENT=lumina
20
+ Environment=SKCAPSTONE_AGENT=lumina
21
+ Environment=PATH=%h/.skenv/bin:/usr/local/bin:/usr/bin:/bin
package/tests/conftest.py CHANGED
@@ -19,6 +19,27 @@ from pathlib import Path
19
19
  import pytest
20
20
 
21
21
 
22
+ @pytest.fixture(autouse=True)
23
+ def _isolate_agent_env(monkeypatch):
24
+ """Prevent host SKCAPSTONE_AGENT / SKMEMORY_AGENT from leaking into unit tests.
25
+
26
+ The profile-aware runtime reads both the env var and the module-level
27
+ skcapstone.SKCAPSTONE_AGENT (set at import time). We clear both so that
28
+ _memory_dir() falls back to the flat "home/memory" layout expected by
29
+ tests that use the tmp_agent_home fixture.
30
+ Tests that need a specific agent should override explicitly via monkeypatch.
31
+ """
32
+ monkeypatch.delenv("SKCAPSTONE_AGENT", raising=False)
33
+ monkeypatch.delenv("SKMEMORY_AGENT", raising=False)
34
+ import skcapstone
35
+ monkeypatch.setattr(skcapstone, "SKCAPSTONE_AGENT", "")
36
+ # _detect_active_agent() scans ~/.skcapstone/agents/ even when the env var
37
+ # is cleared, returning a real agent name that routes memory writes to the
38
+ # wrong directory. Stub it out so tests using tmp directories get the flat
39
+ # "home/memory" layout they expect.
40
+ monkeypatch.setattr(skcapstone, "_detect_active_agent", lambda root=None: None)
41
+
42
+
22
43
  @pytest.fixture
23
44
  def tmp_agent_home(tmp_path: Path) -> Path:
24
45
  """Provide a temporary agent home directory for testing."""
@@ -0,0 +1,34 @@
1
+ """Tests for fresh agent-home scaffolding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ from skcapstone.migrate_multi_agent import create_agent_home
10
+
11
+
12
+ class TestCreateAgentHome:
13
+ def test_manifest_includes_human_operator_when_available(self, tmp_path: Path):
14
+ """New agent homes persist the linked human operator in manifest.json."""
15
+ with patch(
16
+ "skcapstone.migrate_multi_agent.discover_human_operator",
17
+ return_value={"name": "Casey", "fingerprint": "FP123", "relationship": "human-operator"},
18
+ ):
19
+ result = create_agent_home(tmp_path, "teddy")
20
+
21
+ manifest = json.loads((tmp_path / "agents" / "teddy" / "manifest.json").read_text())
22
+ assert result["agent_name"] == "teddy"
23
+ assert manifest["name"] == "teddy"
24
+ assert manifest["operator"]["name"] == "Casey"
25
+ assert manifest["operator"]["fingerprint"] == "FP123"
26
+
27
+ def test_manifest_omits_operator_when_none_available(self, tmp_path: Path):
28
+ """New agent homes still create a valid manifest when no operator exists yet."""
29
+ with patch("skcapstone.migrate_multi_agent.discover_human_operator", return_value=None):
30
+ create_agent_home(tmp_path, "lumina")
31
+
32
+ manifest = json.loads((tmp_path / "agents" / "lumina" / "manifest.json").read_text())
33
+ assert manifest["name"] == "lumina"
34
+ assert "operator" not in manifest
@@ -237,7 +237,8 @@ class TestBackupManifest:
237
237
  def test_manifest_defaults(self) -> None:
238
238
  """Manifest has sensible defaults."""
239
239
  m = BackupManifest()
240
- assert m.version == "0.9.0"
240
+ import skcapstone
241
+ assert m.version == skcapstone.__version__
241
242
  assert m.files == {}
242
243
  assert m.total_size == 0
243
244