@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716233548

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 (108) hide show
  1. package/dist/cli/composer-commands.d.ts +37 -0
  2. package/dist/cli/composer-commands.d.ts.map +1 -0
  3. package/dist/cli/composer-commands.js +396 -0
  4. package/dist/cli/composer-commands.js.map +1 -0
  5. package/dist/cli/senpi-commands.d.ts.map +1 -1
  6. package/dist/cli/senpi-commands.js +2 -0
  7. package/dist/cli/senpi-commands.js.map +1 -1
  8. package/dist/cli/validate-runtime-config.mjs +190 -0
  9. package/dist/composer/AUTHORING.md +435 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +59 -0
  12. package/dist/composer/authoring/answers.py +297 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +26 -0
  14. package/dist/composer/authoring/archetypes/_common.py +410 -0
  15. package/dist/composer/authoring/archetypes/breakout.py +134 -0
  16. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  17. package/dist/composer/authoring/archetypes/skeleton.py +76 -0
  18. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  19. package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
  20. package/dist/composer/authoring/builder.py +191 -0
  21. package/dist/composer/authoring/emit.py +264 -0
  22. package/dist/composer/authoring/gaps.py +115 -0
  23. package/dist/composer/authoring/generate.py +131 -0
  24. package/dist/composer/authoring/presets.py +168 -0
  25. package/dist/composer/authoring/runtime_emit.py +266 -0
  26. package/dist/composer/authoring/strategy_manifest.py +94 -0
  27. package/dist/composer/cli.py +487 -0
  28. package/dist/composer/compiler/__init__.py +22 -0
  29. package/dist/composer/compiler/compile.py +739 -0
  30. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  31. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  32. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  33. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  34. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  35. package/dist/composer/generated/README.md +22 -0
  36. package/dist/composer/generated/catalog.md +118 -0
  37. package/dist/composer/generated/describe.md +1168 -0
  38. package/dist/composer/generated/graph.schema.json +1946 -0
  39. package/dist/composer/primitives/__init__.py +320 -0
  40. package/dist/composer/primitives/_candles.py +76 -0
  41. package/dist/composer/primitives/adapter.py +363 -0
  42. package/dist/composer/primitives/emitters.py +142 -0
  43. package/dist/composer/primitives/filters.py +71 -0
  44. package/dist/composer/primitives/indicators.py +326 -0
  45. package/dist/composer/primitives/rankers.py +40 -0
  46. package/dist/composer/primitives/scorers.py +100 -0
  47. package/dist/composer/primitives/sources.py +979 -0
  48. package/dist/composer/primitives/stateful.py +254 -0
  49. package/dist/composer/pure_fn/__init__.py +34 -0
  50. package/dist/composer/pure_fn/checks.py +39 -0
  51. package/dist/composer/pure_fn/models.py +177 -0
  52. package/dist/composer/pure_fn/purity.py +311 -0
  53. package/dist/composer/pure_fn/runtests.py +155 -0
  54. package/dist/composer/registry/__init__.py +40 -0
  55. package/dist/composer/registry/generate.py +425 -0
  56. package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
  57. package/dist/composer/registry/mcp_closure.py +311 -0
  58. package/dist/composer/registry/models.py +417 -0
  59. package/dist/composer/registry/nodes.py +1297 -0
  60. package/dist/composer/requirements.txt +21 -0
  61. package/dist/composer/scaffold_path.py +118 -0
  62. package/dist/composer/templates/__init__.py +13 -0
  63. package/dist/composer/templates/scan.py.j2 +34 -0
  64. package/dist/composer/validator/__init__.py +29 -0
  65. package/dist/composer/validator/desugar.py +133 -0
  66. package/dist/composer/validator/errors.py +227 -0
  67. package/dist/composer/validator/graph_checks.py +443 -0
  68. package/dist/composer/validator/loader.py +110 -0
  69. package/dist/composer/validator/schema_checks.py +147 -0
  70. package/dist/composer/validator/validate.py +86 -0
  71. package/dist/composer/vendoring.py +103 -0
  72. package/dist/composer/verbs/__init__.py +49 -0
  73. package/dist/composer/verbs/_box.py +308 -0
  74. package/dist/composer/verbs/check.py +254 -0
  75. package/dist/composer/verbs/close.py +494 -0
  76. package/dist/composer/verbs/deploy.py +279 -0
  77. package/dist/composer/verbs/docs.py +30 -0
  78. package/dist/composer/verbs/fund.py +498 -0
  79. package/dist/composer/verbs/install.py +586 -0
  80. package/dist/composer/verbs/runtime_validate.py +422 -0
  81. package/dist/composer/verbs/smoke.py +134 -0
  82. package/dist/composer/verbs/status.py +383 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -9
  85. package/dist/index.js.map +1 -1
  86. package/dist/runtime/load-runtime-config.d.ts +17 -0
  87. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  88. package/dist/runtime/load-runtime-config.js +2 -2
  89. package/dist/runtime/load-runtime-config.js.map +1 -1
  90. package/dist/runtime-api/config.d.ts +2 -2
  91. package/dist/runtime-api/config.js +4 -4
  92. package/dist/runtime-api/config.js.map +1 -1
  93. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  94. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  95. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  96. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  97. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.js +2 -8
  99. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  100. package/dist/skills-manager/manifest.d.ts.map +1 -1
  101. package/dist/skills-manager/manifest.js +1 -3
  102. package/dist/skills-manager/manifest.js.map +1 -1
  103. package/dist/skills-manager/skills-manifest.json +1 -3
  104. package/package.json +4 -2
  105. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  106. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  107. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  108. package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
@@ -0,0 +1,586 @@
1
+ """install — bind a staged unit onto a box and hand it to the frozen consumer (slice A2).
2
+
3
+ ``composer deploy`` STAGES a complete, standalone, pristine unit (placeholder wallet intact).
4
+ ``composer install <target> --wallet 0x…`` is the step that actually puts one instance of that
5
+ unit onto THIS box and starts it, without changing anything the runtime/gateway already does
6
+ (the ``openclaw senpi runtime create`` path is FROZEN — this verb only feeds it).
7
+
8
+ Five steps, each derivable from disk afterwards (resumable-by-artifact; no hidden state):
9
+
10
+ (a) PREFLIGHT — resolve the staged tree, verify every staged artifact's bytes against
11
+ ``manifest.json`` (tamper/hand-edit detection, CMP201), and confirm the recorded oracle
12
+ verdict is GREEN (VALID; a SKIPPED verdict from an oracle-less box is allowed, INVALID is
13
+ CMP202). "GREEN means installable" — this is the install-time reuse of the deploy oracle.
14
+
15
+ (b) COPY to the CANONICAL, CONTENT-ADDRESSED, IMMUTABLE dir
16
+ ``<state>/strategies/<strategy>/<hash8>/`` — write to a temp sibling, verify every copied
17
+ file against the manifest hash (CMP203), then atomic-rename into place. The dir is NEVER
18
+ edited after it lands, so the frozen inline-YAML in the registry can never drift from the
19
+ scan.py re-read off disk on a scaffold respawn. ``hash8`` = the content identity of the
20
+ PRISTINE staged unit (see :func:`compute_hash8`), so identical content always maps to the
21
+ same dir and a re-install is an idempotent no-op.
22
+
23
+ (c) WALLET BIND — substitute the real ``--wallet`` for the ``${..._WALLET}`` placeholder in the
24
+ COPIED ``main/runtime.yaml`` ONLY (the staged source stays pristine, placeholder intact),
25
+ record the binding in ``install.json`` beside the copied tree, and re-run the placeholder
26
+ policy on the bound YAML: after binding, ANY remaining bare ``${VAR}`` is an error (CMP115).
27
+
28
+ (d) INVOKE the frozen consumer through an INJECTABLE SEAM (``create_fn``): in production it
29
+ shells out to ``openclaw senpi runtime create -p <hashdir>/main/runtime.yaml`` (PATH mode —
30
+ never ``--content``, which would drop the scanner dir the external scanner needs); tests
31
+ fake it.
32
+
33
+ (e) VERIFY + REPORT — map the known installRuntime failure classes (duplicate id,
34
+ one-runtime-per-wallet, wallet not ACTIVE, YAML invalid, entrypoint missing) to
35
+ teaching-shaped errors telling the user exactly what to do next (CMP210-219).
36
+
37
+ Scope (phase 1): the agent supplies ``--wallet``; wallet creation/funding is OUT. The
38
+ update/reinstall flow (delete + create) is OUT beyond the teaching error that names the delete
39
+ command. No wall-clock is baked in: the binding timestamp is an injected value (``bound_at``).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import os
46
+ import re
47
+ import shutil
48
+ import subprocess
49
+ import uuid
50
+ from dataclasses import dataclass, field
51
+ from pathlib import Path
52
+ from typing import Callable, Optional
53
+
54
+ from composer.validator.errors import Location, TeachingError
55
+ from composer.validator.loader import load_graph
56
+
57
+ from . import _box
58
+ from ._box import McpFn
59
+ from .runtime_validate import bare_env_placeholders
60
+
61
+ INSTALLED = "INSTALLED"
62
+ ALREADY_INSTALLED = "ALREADY_INSTALLED"
63
+ REFUSED = "REFUSED"
64
+ FAILED = "FAILED"
65
+
66
+ INSTALL_JSON = "install.json"
67
+ REGISTRY_FILENAME = "installed_runtimes.json"
68
+
69
+ # The ops-bound placeholder the composer emits (exactly one, at strategy.wallet). Mirrors
70
+ # composer/authoring/runtime_emit.py::_env_wallet — ${<SLUG>_WALLET}.
71
+ _WALLET_TOKEN = re.compile(r"\$\{[A-Za-z0-9_]+_WALLET\}")
72
+ # A 0x… EVM address (light format gate; ownership is the gateway's job, best-effort).
73
+ _WALLET_ADDR = re.compile(r"^0x[0-9a-fA-F]{40}$")
74
+
75
+
76
+ # --------------------------------------------------------------------------- injectable seam
77
+
78
+ @dataclass
79
+ class CreateResult:
80
+ """What the frozen ``openclaw senpi runtime create`` consumer returned. The seam yields
81
+ this so the mapper reads exit code + text without knowing HOW it was produced."""
82
+
83
+ returncode: int
84
+ stdout: str = ""
85
+ stderr: str = ""
86
+
87
+ @property
88
+ def ok(self) -> bool:
89
+ return self.returncode == 0
90
+
91
+ @property
92
+ def text(self) -> str:
93
+ return f"{self.stdout}\n{self.stderr}"
94
+
95
+
96
+ CreateFn = Callable[[str, str], CreateResult]
97
+
98
+
99
+ def _default_create(runtime_yaml_path: str, runtime_id: str) -> CreateResult:
100
+ """Production seam: shell out to the FROZEN consumer in PATH mode (never --content — the
101
+ external scanner needs the recipe dir the -p path carries; index.ts:1088, senpi-commands.ts:94).
102
+ Captures stdout/stderr so the verdict is parsed truthfully rather than inherited to the tty."""
103
+ openclaw = os.environ.get("OPENCLAW_BIN", "openclaw")
104
+ proc = subprocess.run( # noqa: S603 — fixed argv, no shell
105
+ [openclaw, "senpi", "runtime", "create", "-p", runtime_yaml_path, "--runtime-id", runtime_id],
106
+ capture_output=True,
107
+ text=True,
108
+ )
109
+ return CreateResult(proc.returncode, proc.stdout or "", proc.stderr or "")
110
+
111
+
112
+ # --------------------------------------------------------------------------- content identity
113
+
114
+ def compute_hash8(manifest: dict) -> str:
115
+ """The content identity of a PRISTINE staged unit: the first 8 hex chars of a sha256 over the
116
+ manifest ``artifacts`` map (sorted ``relpath\\0filesha\\n`` — the same stable aggregation
117
+ deploy uses for ``manifest.primitives.sha256``, but over EVERY artifact).
118
+
119
+ Deterministic and wallet-independent: the staged runtime.yaml carries the ``${..._WALLET}``
120
+ PLACEHOLDER, not a wallet, so authoring the same unit always yields the same hash8 → the same
121
+ immutable on-box dir → an idempotent re-install. Binding a wallet mutates only the COPY, never
122
+ this identity."""
123
+ import hashlib
124
+
125
+ artifacts = manifest.get("artifacts") or {}
126
+ h = hashlib.sha256()
127
+ for rel in sorted(artifacts):
128
+ h.update(rel.encode("utf-8"))
129
+ h.update(b"\0")
130
+ h.update(str(artifacts[rel]).encode("utf-8"))
131
+ h.update(b"\n")
132
+ return h.hexdigest()[:8]
133
+
134
+
135
+ def _sha256_file(path: Path) -> str:
136
+ import hashlib
137
+
138
+ return hashlib.sha256(path.read_bytes()).hexdigest()
139
+
140
+
141
+ def derive_runtime_id(strategy: str, hash8: str) -> str:
142
+ """``<strategy>-<hash8>`` sanitized to the registry's id charset (mirrors
143
+ strategy-registry.ts::sanitizeId: [A-Za-z0-9_-], collapse repeats, ≤64).
144
+
145
+ Content-keyed on purpose: the SAME staged content always derives the SAME id, so a duplicate
146
+ is meaningful ("this exact strategy version is already installed") and the whole verb is
147
+ resumable-by-artifact. installRuntime re-sanitizes, so this is already its final form."""
148
+ raw = f"{strategy}-{hash8}"
149
+ out = re.sub(r"[^A-Za-z0-9_-]", "_", raw)
150
+ out = re.sub(r"_+", "_", out).lstrip("_")
151
+ return (out or "runtime")[:64]
152
+
153
+
154
+ # --------------------------------------------------------------------------- result
155
+
156
+ @dataclass
157
+ class InstallResult:
158
+ target: str
159
+ verdict: str
160
+ wallet: str
161
+ hash8: Optional[str] = None
162
+ runtime_id: Optional[str] = None
163
+ canonical_dir: Optional[str] = None
164
+ errors: list[TeachingError] = field(default_factory=list)
165
+ message: str = ""
166
+
167
+ @property
168
+ def ok(self) -> bool:
169
+ return self.verdict in (INSTALLED, ALREADY_INSTALLED)
170
+
171
+ def to_json(self) -> dict:
172
+ return {
173
+ "verb": "install",
174
+ "target": self.target,
175
+ "verdict": self.verdict,
176
+ "wallet": self.wallet,
177
+ "hash8": self.hash8,
178
+ "runtime_id": self.runtime_id,
179
+ "canonical_dir": self.canonical_dir,
180
+ "error_count": len(self.errors),
181
+ "errors": [e.to_dict() for e in self.errors],
182
+ "message": self.message,
183
+ }
184
+
185
+ def render(self) -> str:
186
+ head = f"{self.verdict}: {self.target}"
187
+ lines = [head]
188
+ if self.hash8:
189
+ lines.append(f" hash8 : {self.hash8}")
190
+ if self.runtime_id:
191
+ lines.append(f" runtime id : {self.runtime_id}")
192
+ if self.canonical_dir:
193
+ lines.append(f" on-box dir : {self.canonical_dir}")
194
+ if self.wallet:
195
+ lines.append(f" wallet : {self.wallet}")
196
+ if self.message:
197
+ lines.append(f" {self.message}")
198
+ for e in self.errors:
199
+ lines.append(e.render())
200
+ return "\n".join(lines) + "\n"
201
+
202
+
203
+ def _refused(target: str, wallet: str, error: TeachingError, **kw) -> InstallResult:
204
+ return InstallResult(target=target, verdict=REFUSED, wallet=wallet, errors=[error],
205
+ message=error.message, **kw)
206
+
207
+
208
+ # --------------------------------------------------------------------------- target resolution
209
+
210
+ def _resolve_staged(target: str, out_dir: Optional[str]) -> Optional[Path]:
211
+ """Resolve ``target`` into the staged tree dir (the dir containing ``manifest.json``).
212
+
213
+ Accepts either the staged package dir directly, OR a graph source file plus ``--out`` — then
214
+ the staged tree is ``<out>/<strategy>`` (matching deploy's layout). Returns None if no staged
215
+ manifest is found (CMP200)."""
216
+ p = Path(target)
217
+ if p.is_dir() and (p / "manifest.json").is_file():
218
+ return p
219
+ if p.is_file() and out_dir is not None:
220
+ try:
221
+ doc, _ = load_graph(p.read_text(encoding="utf-8"))
222
+ strategy = str((doc.get("metadata") or {}).get("strategy")
223
+ or p.stem.replace(".graph", ""))
224
+ except Exception: # noqa: BLE001
225
+ strategy = p.stem.replace(".graph", "")
226
+ candidate = Path(out_dir) / strategy
227
+ if (candidate / "manifest.json").is_file():
228
+ return candidate
229
+ return None
230
+
231
+
232
+ def _auto_bind_wallet(target: str, out_dir: Optional[str], base_state_dir: str,
233
+ mcp_fn: McpFn) -> tuple[str, Optional[TeachingError]]:
234
+ """Resolve a wallet WITHOUT an explicit ``--wallet``: read the strategy's ``wallet.json``
235
+ (written by ``composer fund``) and bind it ONLY if MCP confirms the strategy is ACTIVE.
236
+
237
+ Returns ``(wallet, None)`` on a confirmed ACTIVE wallet, or ``("", TeachingError)`` otherwise
238
+ (CMP206 for missing / pending / stale / unverifiable; CMP223 when MCP creds are absent)."""
239
+ # Imported lazily to avoid an import cycle (fund/close import install at module load).
240
+ from ._box import LIVE_STATUSES, McpCredsMissing, McpError
241
+ from .close import _find_list, _strat_id, _strat_status, _strat_wallet
242
+ from .fund import _match_strategy, _read_wallet_json, wallet_json_path
243
+
244
+ def _cmp206(msg: str, fix: str) -> tuple[str, TeachingError]:
245
+ return "", TeachingError("CMP206", msg, fix, Location())
246
+
247
+ staged = _resolve_staged(target, out_dir)
248
+ if staged is None:
249
+ return _cmp206(
250
+ "`--wallet` was omitted and the target is not a staged unit (no manifest.json found).",
251
+ "Pass `--wallet 0x…`, or point at a `composer deploy` staging dir (plus `-o` if the "
252
+ "target is a graph file) so the strategy's wallet.json can be located.",
253
+ )
254
+ try:
255
+ manifest = json.loads((staged / "manifest.json").read_text(encoding="utf-8"))
256
+ except (OSError, json.JSONDecodeError):
257
+ manifest = {}
258
+ strategy = str(manifest.get("strategy") or staged.name)
259
+ wj = wallet_json_path(base_state_dir, strategy)
260
+ rec = _read_wallet_json(wj)
261
+ if rec is None:
262
+ return _cmp206(
263
+ f"`--wallet` was omitted and no funded wallet.json exists for {strategy!r}.",
264
+ f"Run `composer fund <target> --budget N` first (it creates + funds the wallet and "
265
+ f"writes {wj}), then re-run `composer install` to auto-bind it — or pass `--wallet 0x…` "
266
+ f"explicitly.",
267
+ )
268
+ rec_wallet = str(rec.get("wallet") or "")
269
+ rec_sid = rec.get("strategyId")
270
+ try:
271
+ strategies = _find_list(mcp_fn("strategy_list", status=LIVE_STATUSES), "strategies")
272
+ except McpCredsMissing as e:
273
+ return "", TeachingError(
274
+ "CMP223", f"MCP credentials missing: {e}",
275
+ "Set SENPI_AUTH_TOKEN or SENPI_API_KEY (and optionally SENPI_MCP_URL) — auto-binding "
276
+ "a wallet.json requires MCP to confirm the strategy is ACTIVE. Or pass `--wallet 0x…`.",
277
+ Location(),
278
+ )
279
+ except McpError:
280
+ return _cmp206(
281
+ f"`--wallet` was omitted; could not reach MCP to confirm {strategy!r} is ACTIVE.",
282
+ "Re-run once MCP is reachable, or pass `--wallet 0x…` explicitly.",
283
+ )
284
+ s = _match_strategy(strategies, rec_sid, rec_wallet)
285
+ status = str(_strat_status(s) or "").upper() if s else ""
286
+ addr = str(_strat_wallet(s) or "") if s else ""
287
+ if status == "ACTIVE" and (addr or rec_wallet):
288
+ return (addr or rec_wallet), None
289
+ if s is not None and status:
290
+ return _cmp206(
291
+ f"the wallet.json for {strategy!r} exists but its strategy is not ACTIVE (status "
292
+ f"{status}).",
293
+ "Run `composer fund <target> --budget N` to poll it to ACTIVE (idempotent), then "
294
+ "re-run `composer install`. Explicit `--wallet 0x…` bypasses this check.",
295
+ )
296
+ return _cmp206(
297
+ f"the wallet.json for {strategy!r} is stale — its strategy is not in your active set.",
298
+ f"It was likely closed. Delete the stale marker `{wj}` and run `composer fund <target> "
299
+ f"--budget N` for a fresh wallet, then re-run `composer install`.",
300
+ )
301
+
302
+
303
+ # --------------------------------------------------------------------------- installRuntime mapper
304
+
305
+ def _map_create_failure(res: CreateResult) -> TeachingError:
306
+ """Map a FROZEN-consumer failure to a teaching error by matching the stable substrings the
307
+ installRuntime RPC emits (src/index.ts:1074-1347). Order matters — the most specific class
308
+ first — so a generic fallback (CMP219) only catches genuinely unrecognised failures."""
309
+ t = res.text
310
+ if "already exists" in t and "runtime with id" in t:
311
+ return TeachingError(
312
+ "CMP210", "a runtime with this id already exists on the box.",
313
+ "This exact strategy version is already installed. Tear it down first with "
314
+ "`composer close <strategy>` (the sanctioned path — it stops the runtime, flattens "
315
+ "positions, and returns funds), then re-install. The update/reinstall flow is out of "
316
+ "phase-1 scope.",
317
+ Location(),
318
+ )
319
+ if "is already running" in t:
320
+ return TeachingError(
321
+ "CMP211", "a runtime for this wallet is already running (one running runtime per wallet).",
322
+ "The box allows ONE running runtime per wallet. Tear the existing one down with "
323
+ "`composer close <strategy>` (stops the runtime, flattens positions, returns funds), "
324
+ "then re-install. Its id is in the message above and in `openclaw senpi runtime list`.",
325
+ Location(),
326
+ )
327
+ if "was not found in your Senpi account" in t or "requires ACTIVE status" in t or "has status" in t:
328
+ return TeachingError(
329
+ "CMP212", "the strategy wallet is not ACTIVE (or not found in your Senpi account).",
330
+ "A runtime can only bind an ACTIVE strategy wallet. Call the MCP tool `strategy_list` "
331
+ "and use a wallet whose status is ACTIVE for `--wallet`; if it is still initializing, "
332
+ "wait for it to reach ACTIVE and retry.",
333
+ Location(),
334
+ )
335
+ if "entrypoint not found" in t:
336
+ return TeachingError(
337
+ "CMP214", "the external-scanner entrypoint (scan.py) was not found on disk.",
338
+ "The canonical hash dir is missing its scan module. This should not happen after a "
339
+ "verified copy — re-run `composer install` (the copy is re-verified against the "
340
+ "manifest), and if it persists re-`composer deploy` the graph.",
341
+ Location(),
342
+ )
343
+ if "invalid or incomplete YAML" in t or "Could not start the runtime" in t:
344
+ return TeachingError(
345
+ "CMP213", "the runtime YAML was invalid/incomplete at load.",
346
+ "The gateway rejected the runtime.yaml at load. Re-run `composer check <graph>` (its "
347
+ "runtime oracle mirrors the loader) and re-`composer deploy`; do not hand-edit the "
348
+ "staged or copied YAML.",
349
+ Location(),
350
+ )
351
+ detail = (res.stderr or res.stdout or "").strip()[:300]
352
+ return TeachingError(
353
+ "CMP219", f"`openclaw senpi runtime create` failed (exit {res.returncode}).",
354
+ "The frozen install consumer failed for an unrecognised reason. Check its output"
355
+ + (f": {detail}" if detail else "") + ". Verify the box is provisioned "
356
+ "(`openclaw senpi runtime list` works) and retry.",
357
+ Location(),
358
+ )
359
+
360
+
361
+ # --------------------------------------------------------------------------- the verb
362
+
363
+ def run_install(
364
+ target: str,
365
+ wallet: Optional[str],
366
+ base_state_dir: str,
367
+ *,
368
+ out_dir: Optional[str] = None,
369
+ runtime_id: Optional[str] = None,
370
+ bound_at: Optional[str] = None,
371
+ create_fn: CreateFn = _default_create,
372
+ mcp_fn: McpFn = _box.default_mcp_call,
373
+ ) -> InstallResult:
374
+ """Install one instance of a staged unit onto this box (see module docstring)."""
375
+ wallet = (wallet or "").strip()
376
+ auto_bound = False
377
+
378
+ # (a0) WALLET RESOLUTION. Explicit `--wallet` ALWAYS wins. When it is omitted, try to
379
+ # auto-bind the strategy's funded wallet from `<state>/strategies/<strategy>/wallet.json`
380
+ # (written by `composer fund`) — but ONLY if MCP confirms that strategy is ACTIVE.
381
+ if not wallet:
382
+ resolved, err = _auto_bind_wallet(target, out_dir, base_state_dir, mcp_fn)
383
+ if err is not None:
384
+ return _refused(target, "", err)
385
+ wallet, auto_bound = resolved, True
386
+
387
+ # (a0) --wallet gate — a light 0x… format check; ownership/ACTIVE is the gateway's job.
388
+ if not _WALLET_ADDR.match(wallet):
389
+ return _refused(target, wallet, TeachingError(
390
+ "CMP205", "`--wallet` is missing or not a 0x… address.",
391
+ "Pass the strategy's ACTIVE wallet: `composer install <staged-dir> --wallet 0x…` "
392
+ "(40 hex chars), or run `composer fund <target> --budget N` first and omit `--wallet` "
393
+ "to auto-bind the funded wallet. Get an address from MCP `strategy_list` "
394
+ "(strategyWalletAddress).",
395
+ Location(),
396
+ ))
397
+
398
+ # (a) PREFLIGHT — staged tree exists.
399
+ staged = _resolve_staged(target, out_dir)
400
+ if staged is None:
401
+ return _refused(target, wallet, TeachingError(
402
+ "CMP200", "the install target is not a staged unit (no manifest.json found).",
403
+ "Point `composer install` at a `composer deploy` staging dir (the one containing "
404
+ "manifest.json + main/), or pass the graph file plus `-o <staging-dir>`. Run "
405
+ "`composer deploy <graph> -o <dir>` first.",
406
+ Location(),
407
+ ))
408
+
409
+ try:
410
+ manifest = json.loads((staged / "manifest.json").read_text(encoding="utf-8"))
411
+ except (OSError, json.JSONDecodeError):
412
+ return _refused(target, wallet, TeachingError(
413
+ "CMP200", "the staged manifest.json could not be read/parsed.",
414
+ "Re-run `composer deploy <graph> -o <dir>` to regenerate a valid staged unit.",
415
+ Location(),
416
+ ))
417
+
418
+ # (a) PREFLIGHT — every staged artifact's bytes match its manifest hash (tamper/hand-edit).
419
+ artifacts: dict = manifest.get("artifacts") or {}
420
+ mismatched = _verify_tree(staged, artifacts)
421
+ if mismatched:
422
+ return _refused(target, wallet, TeachingError(
423
+ "CMP201", f"staged artifact(s) do not match manifest.json: {', '.join(mismatched)}.",
424
+ "The staging dir was corrupted or hand-edited after deploy. Never edit a staged unit "
425
+ "in place — re-run `composer deploy <graph> -o <dir>` to restage from the graph.",
426
+ Location(),
427
+ ))
428
+
429
+ # (a) PREFLIGHT — the recorded oracle verdict must be GREEN. VALID = green; SKIPPED = allowed
430
+ # (oracle-less box, mirrors deploy/check's own skip-is-not-red policy); INVALID is blocked.
431
+ zod_verdict = str((manifest.get("runtime") or {}).get("zod_verdict") or "")
432
+ if zod_verdict == "INVALID":
433
+ return _refused(target, wallet, TeachingError(
434
+ "CMP202", "the staged unit's recorded oracle verdict is INVALID — it will not install.",
435
+ "The runtime.yaml failed the Zod/DSL oracle at deploy. Fix the graph, re-run "
436
+ "`composer check <graph>` until GREEN, then `composer deploy` before installing.",
437
+ Location(),
438
+ ))
439
+
440
+ strategy = str(manifest.get("strategy") or staged.name)
441
+ hash8 = compute_hash8(manifest)
442
+ rid = runtime_id or derive_runtime_id(strategy, hash8)
443
+ canonical = Path(base_state_dir) / "strategies" / strategy / hash8
444
+ installed_marker = _registry_has(Path(base_state_dir), rid)
445
+
446
+ # (b) COPY into the canonical, content-addressed, immutable dir (idempotent).
447
+ if canonical.exists():
448
+ # Same content dir already present — check it is bound to THIS wallet (CMP204 otherwise).
449
+ prior = _read_install_json(canonical)
450
+ if prior is not None and prior.get("wallet") and prior["wallet"] != wallet:
451
+ return _refused(target, wallet, TeachingError(
452
+ "CMP204", f"this content is already installed on the box bound to wallet "
453
+ f"{prior['wallet']}.",
454
+ "A content-addressed hash dir is immutable and holds ONE wallet binding. To run the "
455
+ "same strategy on a different wallet, re-author it as a distinct strategy (the "
456
+ "multi-wallet/update flow is out of phase-1 scope).",
457
+ Location(),
458
+ ), hash8=hash8, runtime_id=rid, canonical_dir=str(canonical))
459
+ # Idempotent no-op: content already landed for this wallet.
460
+ if installed_marker:
461
+ return InstallResult(
462
+ target=target, verdict=ALREADY_INSTALLED, wallet=wallet, hash8=hash8,
463
+ runtime_id=rid, canonical_dir=str(canonical),
464
+ message=f"runtime {rid!r} already installed for this content + wallet (no-op).",
465
+ )
466
+ # Dir landed + bound but never registered (interrupted run) — resume at (d), skip re-copy.
467
+ else:
468
+ err = _copy_verified(staged, canonical, artifacts)
469
+ if err is not None:
470
+ return _refused(target, wallet, err, hash8=hash8, runtime_id=rid)
471
+
472
+ # (c) WALLET BIND — into the COPIED runtime.yaml only; the staged source stays pristine.
473
+ copied_runtime = canonical / "main" / "runtime.yaml"
474
+ bound_text, n_bound = _WALLET_TOKEN.subn(wallet, copied_runtime.read_text(encoding="utf-8"))
475
+ copied_runtime.write_text(bound_text, encoding="utf-8")
476
+
477
+ # Re-run the placeholder policy on the BOUND yaml: any remaining bare ${VAR} is now an error.
478
+ leftover = bare_env_placeholders(bound_text)
479
+ if leftover:
480
+ return _refused(target, wallet, TeachingError(
481
+ "CMP115", f"unbound env placeholder(s) remain after wallet binding: {', '.join(leftover)}.",
482
+ "Only the `${<STRATEGY>_WALLET}` placeholder is bound at install; every other `${VAR}` "
483
+ "resolves to \"\" at load and breaks the config. Remove it or give it a default "
484
+ "(`${VAR:-default}`) in the graph, then re-`composer deploy`.",
485
+ Location(path=""),
486
+ ), hash8=hash8, runtime_id=rid, canonical_dir=str(canonical))
487
+
488
+ _write_install_json(canonical, wallet=wallet, hash8=hash8, strategy=strategy,
489
+ runtime_id=rid, bound_count=n_bound, bound_at=bound_at)
490
+
491
+ # (d) INVOKE the frozen consumer through the seam (PATH mode).
492
+ create = create_fn(str(copied_runtime), rid)
493
+
494
+ # (e) VERIFY + REPORT.
495
+ if create.ok:
496
+ auto = " (wallet auto-bound from wallet.json)" if auto_bound else ""
497
+ return InstallResult(
498
+ target=target, verdict=INSTALLED, wallet=wallet, hash8=hash8, runtime_id=rid,
499
+ canonical_dir=str(canonical),
500
+ message=f"installed runtime {rid!r} on wallet {wallet}{auto} from {canonical}.",
501
+ )
502
+ err = _map_create_failure(create)
503
+ return InstallResult(
504
+ target=target, verdict=FAILED, wallet=wallet, hash8=hash8, runtime_id=rid,
505
+ canonical_dir=str(canonical), errors=[err], message=err.message,
506
+ )
507
+
508
+
509
+ # --------------------------------------------------------------------------- fs helpers
510
+
511
+ def _verify_tree(root: Path, artifacts: dict) -> list[str]:
512
+ """Return the sorted relpaths whose on-disk bytes do NOT match the manifest hash (missing
513
+ files count as mismatched)."""
514
+ bad: list[str] = []
515
+ for rel, sha in artifacts.items():
516
+ f = root / rel
517
+ if not f.is_file() or _sha256_file(f) != sha:
518
+ bad.append(rel)
519
+ return sorted(bad)
520
+
521
+
522
+ def _copy_verified(staged: Path, canonical: Path, artifacts: dict) -> Optional[TeachingError]:
523
+ """Copy the staged tree to a temp sibling, verify EVERY manifest artifact against its hash,
524
+ then atomic-rename into ``canonical``. Returns a CMP203 teaching error on a hash mismatch
525
+ (the temp dir is cleaned up and nothing lands). Never edits in place."""
526
+ canonical.parent.mkdir(parents=True, exist_ok=True)
527
+ tmp = canonical.parent / f".{canonical.name}.tmp-{uuid.uuid4().hex[:8]}"
528
+ if tmp.exists():
529
+ shutil.rmtree(tmp)
530
+ shutil.copytree(staged, tmp)
531
+ mismatched = _verify_tree(tmp, artifacts)
532
+ if mismatched:
533
+ shutil.rmtree(tmp, ignore_errors=True)
534
+ return TeachingError(
535
+ "CMP203", f"copied file(s) do not match manifest.json: {', '.join(mismatched)}.",
536
+ "The copy into the canonical hash dir was corrupted (tamper or IO fault). Nothing was "
537
+ "installed. Retry `composer install`; if it persists, re-`composer deploy` the graph.",
538
+ Location(),
539
+ )
540
+ os.rename(tmp, canonical)
541
+ return None
542
+
543
+
544
+ def _read_install_json(canonical: Path) -> Optional[dict]:
545
+ p = canonical / INSTALL_JSON
546
+ try:
547
+ return json.loads(p.read_text(encoding="utf-8"))
548
+ except (OSError, json.JSONDecodeError):
549
+ return None
550
+
551
+
552
+ def _write_install_json(canonical: Path, *, wallet: str, hash8: str, strategy: str,
553
+ runtime_id: str, bound_count: int, bound_at: Optional[str]) -> None:
554
+ """Record the wallet binding beside the copied tree (the resumable-by-artifact marker for
555
+ step c). ``bound_at`` is an INJECTED value (no wall-clock) — None when the caller omits it."""
556
+ payload = {
557
+ "strategy": strategy,
558
+ "wallet": wallet,
559
+ "hash8": hash8,
560
+ "runtime_id": runtime_id,
561
+ "runtime_yaml": "main/runtime.yaml",
562
+ "wallet_bindings_applied": bound_count,
563
+ "bound_at": bound_at,
564
+ }
565
+ (canonical / INSTALL_JSON).write_text(
566
+ json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
567
+ )
568
+
569
+
570
+ def _registry_has(base_state_dir: Path, runtime_id: str) -> bool:
571
+ """True if the box registry already lists ``runtime_id`` (install-complete marker written by
572
+ the frozen installRuntime, strategy-registry.ts). Read-only — the composer never writes it."""
573
+ reg = base_state_dir / REGISTRY_FILENAME
574
+ try:
575
+ data = json.loads(reg.read_text(encoding="utf-8"))
576
+ except (OSError, json.JSONDecodeError):
577
+ return False
578
+ return any(
579
+ isinstance(e, dict) and e.get("id") == runtime_id
580
+ for e in (data.get("runtimes") or [])
581
+ )
582
+
583
+
584
+ def install_target(target, wallet, base_state_dir, **kw) -> InstallResult:
585
+ """Path-taking convenience wrapper mirroring ``deploy_file`` / ``check_file``."""
586
+ return run_install(str(target), wallet, str(base_state_dir), **kw)