recto-core 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. recto/__init__.py +20 -0
  2. recto/__main__.py +16 -0
  3. recto/_launcher_run.py +151 -0
  4. recto/_migrate.py +167 -0
  5. recto/adminui.py +618 -0
  6. recto/bitcoin.py +772 -0
  7. recto/bootloader/__init__.py +54 -0
  8. recto/bootloader/server.py +4187 -0
  9. recto/bootloader/sessions.py +317 -0
  10. recto/bootloader/state.py +2002 -0
  11. recto/capability/__init__.py +76 -0
  12. recto/capability/jwt.py +441 -0
  13. recto/capability/manifest.py +278 -0
  14. recto/capability/manifest_v1.json +176 -0
  15. recto/capability/types.py +229 -0
  16. recto/cli.py +2767 -0
  17. recto/comms.py +482 -0
  18. recto/config.py +587 -0
  19. recto/ethereum.py +875 -0
  20. recto/healthz.py +235 -0
  21. recto/joblimit.py +423 -0
  22. recto/launcher.py +421 -0
  23. recto/nssm.py +457 -0
  24. recto/profile/__init__.py +45 -0
  25. recto/profile/manage.py +917 -0
  26. recto/profile/store.py +453 -0
  27. recto/profile/types.py +410 -0
  28. recto/profile/usb_backup.py +359 -0
  29. recto/reconcile.py +257 -0
  30. recto/restart.py +141 -0
  31. recto/ripple.py +464 -0
  32. recto/secrets/__init__.py +144 -0
  33. recto/secrets/base.py +146 -0
  34. recto/secrets/credman.py +424 -0
  35. recto/secrets/dpapi_machine.py +386 -0
  36. recto/secrets/enclave_stub.py +187 -0
  37. recto/secrets/env.py +56 -0
  38. recto/sign_helper.py +428 -0
  39. recto/solana.py +350 -0
  40. recto/stellar.py +378 -0
  41. recto/telemetry.py +276 -0
  42. recto/tron.py +358 -0
  43. recto_core-1.0.0.dist-info/METADATA +134 -0
  44. recto_core-1.0.0.dist-info/RECORD +48 -0
  45. recto_core-1.0.0.dist-info/WHEEL +5 -0
  46. recto_core-1.0.0.dist-info/entry_points.txt +2 -0
  47. recto_core-1.0.0.dist-info/licenses/LICENSE +201 -0
  48. recto_core-1.0.0.dist-info/top_level.txt +1 -0
recto/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Recto — modern Windows-service wrapper.
2
+
3
+ Public API surface:
4
+ recto.config.load_config — parse + validate a service.yaml
5
+ recto.config.ServiceConfig — top-level dataclass
6
+ recto.secrets.SecretSource — ABC for pluggable secret backends
7
+ recto.secrets.SecretMaterial — sealed type returned by SecretSource.fetch
8
+ recto.secrets.DirectSecret — variant: secret materialized as a string
9
+ recto.secrets.SigningCapability — variant: secret never leaves enclave
10
+ recto.secrets.EnvSource — passthrough backend reading os.environ
11
+ recto.secrets.CredManSource — Windows Credential Manager backend
12
+ recto.secrets.register_source — third-party backend registration
13
+ recto.launcher.launch — read config, fetch secrets, spawn child
14
+
15
+ Higher-level entry points (CLI, healthz probe loop, restart policy, comms
16
+ webhook dispatch) are wired in alongside the launcher in v0.1.
17
+ """
18
+
19
+ __version__ = "0.1.0.dev0"
20
+ __all__ = ["__version__"]
recto/__main__.py ADDED
@@ -0,0 +1,16 @@
1
+ """`python -m recto` entry point.
2
+
3
+ Forwards to recto.cli:main(). The console-script entry registered
4
+ in pyproject.toml (`recto = "recto.cli:main"`) hits the same target,
5
+ so `recto launch ...` and `python -m recto launch ...` are identical.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from recto.cli import main
13
+
14
+
15
+ if __name__ == "__main__": # pragma: no cover
16
+ sys.exit(main())
recto/_launcher_run.py ADDED
@@ -0,0 +1,151 @@
1
+ """Restart-loop entry point split out of recto.launcher.
2
+
3
+ Lives in its own module to keep recto/launcher.py under the
4
+ cross-mount Write-tool truncation threshold encountered during v0.1
5
+ work. Re-exported as `recto.launcher.run` at the bottom of
6
+ recto/launcher.py.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ import time
13
+ from collections.abc import Callable, Mapping
14
+
15
+ from recto.adminui import AdminUIServer, EventBuffer
16
+ from recto.config import ServiceConfig
17
+ from recto.healthz import HealthzProbe
18
+ from recto.joblimit import JobLimit
19
+ from recto.restart import MaxAttemptsReachedError, next_delay, should_restart
20
+ from recto.secrets import SecretSource
21
+ from recto.telemetry import TelemetryClient
22
+
23
+ __all__ = ["run"]
24
+
25
+
26
+ def run(
27
+ config: ServiceConfig,
28
+ *,
29
+ sources: Mapping[str, SecretSource] | None = None,
30
+ popen: Callable[..., "subprocess.Popen"] = subprocess.Popen,
31
+ base_env: Mapping[str, str] | None = None,
32
+ sleep: Callable[[float], None] = time.sleep,
33
+ probe_factory: Callable[..., HealthzProbe] = HealthzProbe,
34
+ poll_interval_seconds: float = 0.5,
35
+ terminate_grace_seconds: float = 5.0,
36
+ dispatcher_factory: Callable[..., object] | None = None,
37
+ joblimit_factory: Callable[..., JobLimit] = JobLimit,
38
+ telemetry_factory: Callable[..., TelemetryClient] = TelemetryClient,
39
+ buffer_factory: Callable[[], EventBuffer] = EventBuffer,
40
+ adminui_factory: Callable[..., AdminUIServer] = AdminUIServer,
41
+ ) -> int:
42
+ """Run the supervised child with the configured restart policy.
43
+
44
+ Compared to launch():
45
+ - launch() is one-shot (single spawn, single bracket).
46
+ - run() brackets init/teardown ONCE around the whole loop and
47
+ re-spawns per restart policy. Long-lived backends (Vault session,
48
+ hardware-enclave handle) stay open across restarts. The
49
+ TelemetryClient also lives across the whole loop -- one OTel
50
+ span covers every restart attempt. Likewise the EventBuffer +
51
+ AdminUIServer cover the whole loop, so /api/events shows the
52
+ full lifecycle history rather than just the latest spawn.
53
+
54
+ Returns the LAST child invocation's exit code, whether the policy
55
+ decided "no restart" or max_attempts_reached fired.
56
+ """
57
+ # Local imports avoid the recto.launcher <-> recto._launcher_run
58
+ # circular import at module load time.
59
+ from recto.launcher import (
60
+ _bracket_lifecycle,
61
+ _build_dispatcher,
62
+ _emit_event,
63
+ _spawn_and_wait,
64
+ build_child_env,
65
+ resolve_sources,
66
+ )
67
+
68
+ if sources is None:
69
+ sources = resolve_sources(config)
70
+
71
+ telemetry = telemetry_factory(config.spec.telemetry)
72
+ telemetry.start_run(
73
+ config.metadata.name,
74
+ attributes={
75
+ "recto.healthz.type": config.spec.healthz.type,
76
+ "recto.restart.policy": config.spec.restart.policy,
77
+ },
78
+ )
79
+ buffer = buffer_factory()
80
+ adminui = adminui_factory(
81
+ config.spec.admin_ui,
82
+ service_name=config.metadata.name,
83
+ buffer=buffer,
84
+ config=config,
85
+ )
86
+ adminui.start()
87
+ last_rc = 0
88
+ try:
89
+ with _bracket_lifecycle(
90
+ config, sources, telemetry=telemetry, buffer=buffer
91
+ ):
92
+ env = build_child_env(config.spec, sources, base_env=base_env)
93
+ dispatcher = _build_dispatcher(config, env, dispatcher_factory)
94
+ attempt = 0
95
+ while True:
96
+ last_rc = _spawn_and_wait(
97
+ config,
98
+ env,
99
+ popen,
100
+ probe_factory=probe_factory,
101
+ poll_interval_seconds=poll_interval_seconds,
102
+ terminate_grace_seconds=terminate_grace_seconds,
103
+ dispatcher=dispatcher,
104
+ joblimit_factory=joblimit_factory,
105
+ telemetry=telemetry,
106
+ buffer=buffer,
107
+ )
108
+ if not should_restart(last_rc, config.spec.restart):
109
+ _emit_event(
110
+ config,
111
+ "run.final_exit",
112
+ {"returncode": last_rc, "restart_attempts": attempt},
113
+ dispatcher=dispatcher,
114
+ telemetry=telemetry,
115
+ buffer=buffer,
116
+ )
117
+ return last_rc
118
+ attempt += 1
119
+ try:
120
+ delay = next_delay(attempt, config.spec.restart)
121
+ except MaxAttemptsReachedError:
122
+ _emit_event(
123
+ config,
124
+ "max_attempts_reached",
125
+ {
126
+ "max_attempts": config.spec.restart.max_attempts,
127
+ "last_returncode": last_rc,
128
+ },
129
+ dispatcher=dispatcher,
130
+ telemetry=telemetry,
131
+ buffer=buffer,
132
+ )
133
+ return last_rc
134
+ _emit_event(
135
+ config,
136
+ "restart.attempt",
137
+ {
138
+ "attempt": attempt,
139
+ "delay_seconds": delay,
140
+ "previous_returncode": last_rc,
141
+ "backoff": config.spec.restart.backoff,
142
+ },
143
+ dispatcher=dispatcher,
144
+ telemetry=telemetry,
145
+ buffer=buffer,
146
+ )
147
+ sleep(delay)
148
+ finally:
149
+ adminui.stop()
150
+ telemetry.end_run(last_rc)
151
+ telemetry.shutdown()
recto/_migrate.py ADDED
@@ -0,0 +1,167 @@
1
+ """Helpers for `recto migrate-from-nssm`. Extracted from recto.cli.
2
+
3
+ These functions are pure (no I/O, no side effects) and small. They
4
+ live in their own module so cli.py stays under the Cowork Write-tool
5
+ size threshold while still being a thin dispatcher rather than a
6
+ 1000-line god-module. Same pattern as ``recto._launcher_run`` --
7
+ private (underscore-prefix) module, re-imported by name from
8
+ ``recto.cli``.
9
+
10
+ If you're reading this in v0.3+: feel free to inline these back into
11
+ cli.py once the Cowork sandbox is no longer the operator's primary
12
+ authoring environment.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from recto.nssm import NssmConfig
21
+
22
+ __all__ = [
23
+ "build_migration_plan",
24
+ "escape_yaml",
25
+ "generate_service_yaml",
26
+ "partition_env_entries",
27
+ ]
28
+
29
+
30
+ def partition_env_entries(
31
+ entries: list[tuple[str, str]],
32
+ *,
33
+ keep_as_env: list[str] | None = None,
34
+ ) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
35
+ """Split NSSM AppEnvironmentExtra entries into (secrets, plain_env).
36
+
37
+ Operators pass ``--keep-as-env=NAME[,NAME...]`` on the
38
+ ``migrate-from-nssm`` CLI to route specific keys into the
39
+ generated YAML's ``spec.env:`` block instead of Credential
40
+ Manager. The default (``keep_as_env=None`` or empty list) is
41
+ conservative: every entry goes to CredMan, matching v0.1
42
+ behavior. No surprise allow-list -- operators opt in
43
+ per-migration.
44
+
45
+ Returns ``(secrets, plain_env)``. Both lists preserve the input
46
+ order.
47
+ """
48
+ keep_set = set(keep_as_env or ())
49
+ secrets: list[tuple[str, str]] = []
50
+ plain_env: list[tuple[str, str]] = []
51
+ for k, v in entries:
52
+ if k in keep_set:
53
+ plain_env.append((k, v))
54
+ else:
55
+ secrets.append((k, v))
56
+ return secrets, plain_env
57
+
58
+
59
+ def build_migration_plan(
60
+ *,
61
+ nssm_cfg: NssmConfig,
62
+ secrets: list[tuple[str, str]],
63
+ yaml_out: Path,
64
+ python_exe: str,
65
+ plain_env: list[tuple[str, str]] | None = None,
66
+ ) -> dict[str, Any]:
67
+ """Build a dict describing what migrate-from-nssm WOULD do.
68
+
69
+ Secret VALUES are masked (replaced with '<redacted>') so the plan
70
+ can be JSON-printed without leaking. Plain-env values (from
71
+ ``--keep-as-env``) are NOT masked since they're explicitly
72
+ operator-tagged as non-secret.
73
+ """
74
+ plain_env = plain_env or []
75
+ return {
76
+ "service": nssm_cfg.service,
77
+ "current_app_path": nssm_cfg.app_path,
78
+ "current_app_parameters": nssm_cfg.app_parameters,
79
+ "current_app_directory": nssm_cfg.app_directory,
80
+ "current_environment_extra_count": len(secrets) + len(plain_env),
81
+ "secrets_to_install": [
82
+ {"name": k, "value": "<redacted>"} for k, _ in secrets
83
+ ],
84
+ "plain_env_to_yaml": [
85
+ {"name": k, "value": v} for k, v in plain_env
86
+ ],
87
+ "yaml_out": str(yaml_out),
88
+ "new_app_path": python_exe,
89
+ "new_app_parameters": f"-m recto launch {yaml_out}",
90
+ }
91
+
92
+
93
+ def generate_service_yaml(
94
+ *,
95
+ service: str,
96
+ nssm_cfg: NssmConfig,
97
+ secret_keys: list[str],
98
+ plain_env: list[tuple[str, str]] | None = None,
99
+ secret_backend: str = "credman",
100
+ ) -> str:
101
+ """Produce the generated service.yaml text.
102
+
103
+ Hand-rolled (not via PyYAML's dump) so we control formatting:
104
+ fields appear in a stable order, comments are preserved, and the
105
+ output is human-reviewable. PyYAML's default style would munge
106
+ quoting on values with `:` or `#` and lose readability.
107
+
108
+ ``plain_env`` (from ``--keep-as-env``) is rendered into the
109
+ YAML's ``spec.env:`` block. ``secret_keys`` becomes the
110
+ ``spec.secrets:`` block.
111
+ """
112
+ plain_env = plain_env or []
113
+ lines: list[str] = []
114
+ a = lines.append
115
+ a("# Generated by `recto migrate-from-nssm`.")
116
+ a(f"# Source NSSM service: {nssm_cfg.service}")
117
+ a("# Review and edit before relying on this in production.")
118
+ a("apiVersion: recto/v1")
119
+ a("kind: Service")
120
+ a("metadata:")
121
+ a(f" name: {service}")
122
+ # Map NSSM's DisplayName -> YAML metadata.display_name (Papercut #3
123
+ # additive field, v0.2.x+) and NSSM's Description -> YAML
124
+ # metadata.description independently. Pre-Papercut-#3 behavior
125
+ # collapsed NSSM DisplayName into a single YAML `description`
126
+ # field, which on round-trip through `recto apply` then wrote the
127
+ # same string back into both NSSM registry parameters -- lossy. New
128
+ # migrations produce two distinct fields when both are set on the
129
+ # source NSSM service. If only DisplayName is set, only display_name
130
+ # is emitted (so `recto apply` won't pollute NSSM's Description from
131
+ # "" to the DisplayName string on round-trip).
132
+ if nssm_cfg.display_name:
133
+ a(f" display_name: \"{escape_yaml(nssm_cfg.display_name)}\"")
134
+ if nssm_cfg.description:
135
+ a(f" description: \"{escape_yaml(nssm_cfg.description)}\"")
136
+ a("spec:")
137
+ a(f" exec: \"{escape_yaml(nssm_cfg.app_path)}\"")
138
+ if nssm_cfg.app_parameters:
139
+ a(" args:")
140
+ for w in nssm_cfg.app_parameters.split():
141
+ a(f" - \"{escape_yaml(w)}\"")
142
+ if nssm_cfg.app_directory:
143
+ a(f" working_dir: \"{escape_yaml(nssm_cfg.app_directory)}\"")
144
+ if secret_keys:
145
+ a(" secrets:")
146
+ for key in secret_keys:
147
+ a(f" - name: {key}")
148
+ a(f" source: {secret_backend}")
149
+ a(f" target_env: {key}")
150
+ a(" required: true")
151
+ if plain_env:
152
+ a(" env:")
153
+ for k, v in plain_env:
154
+ a(f" {k}: \"{escape_yaml(v)}\"")
155
+ a(" restart:")
156
+ a(" policy: always")
157
+ a(" backoff: exponential")
158
+ a(" initial_delay_seconds: 1")
159
+ a(" max_delay_seconds: 60")
160
+ a(" max_attempts: 10")
161
+ a("")
162
+ return "\n".join(lines)
163
+
164
+
165
+ def escape_yaml(s: str) -> str:
166
+ """Escape backslashes and double-quotes for YAML double-quoted strings."""
167
+ return s.replace("\\", "\\\\").replace("\"", "\\\"")