apsimo-hostworker 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. apsimo_hostworker-0.2.0/LICENSE +21 -0
  2. apsimo_hostworker-0.2.0/PKG-INFO +24 -0
  3. apsimo_hostworker-0.2.0/README.md +12 -0
  4. apsimo_hostworker-0.2.0/apsimo_hostworker/__init__.py +134 -0
  5. apsimo_hostworker-0.2.0/apsimo_hostworker/_private_io.py +267 -0
  6. apsimo_hostworker-0.2.0/apsimo_hostworker/admission.py +297 -0
  7. apsimo_hostworker-0.2.0/apsimo_hostworker/catalog.py +754 -0
  8. apsimo_hostworker-0.2.0/apsimo_hostworker/client.py +292 -0
  9. apsimo_hostworker-0.2.0/apsimo_hostworker/compat.py +53 -0
  10. apsimo_hostworker-0.2.0/apsimo_hostworker/conformance/__init__.py +52 -0
  11. apsimo_hostworker-0.2.0/apsimo_hostworker/conformance/__main__.py +29 -0
  12. apsimo_hostworker-0.2.0/apsimo_hostworker/conformance/harness.py +278 -0
  13. apsimo_hostworker-0.2.0/apsimo_hostworker/conformance/suite.py +1109 -0
  14. apsimo_hostworker-0.2.0/apsimo_hostworker/contract.py +402 -0
  15. apsimo_hostworker-0.2.0/apsimo_hostworker/gate.py +446 -0
  16. apsimo_hostworker-0.2.0/apsimo_hostworker/intent.py +221 -0
  17. apsimo_hostworker-0.2.0/apsimo_hostworker/sqlite_store.py +1815 -0
  18. apsimo_hostworker-0.2.0/apsimo_hostworker/store.py +446 -0
  19. apsimo_hostworker-0.2.0/apsimo_hostworker/worker.py +643 -0
  20. apsimo_hostworker-0.2.0/apsimo_hostworker.egg-info/PKG-INFO +24 -0
  21. apsimo_hostworker-0.2.0/apsimo_hostworker.egg-info/SOURCES.txt +34 -0
  22. apsimo_hostworker-0.2.0/apsimo_hostworker.egg-info/dependency_links.txt +1 -0
  23. apsimo_hostworker-0.2.0/apsimo_hostworker.egg-info/requires.txt +3 -0
  24. apsimo_hostworker-0.2.0/apsimo_hostworker.egg-info/top_level.txt +2 -0
  25. apsimo_hostworker-0.2.0/colony_hostworker/__init__.py +4 -0
  26. apsimo_hostworker-0.2.0/pyproject.toml +25 -0
  27. apsimo_hostworker-0.2.0/setup.cfg +4 -0
  28. apsimo_hostworker-0.2.0/tests/test_admission.py +267 -0
  29. apsimo_hostworker-0.2.0/tests/test_catalog.py +151 -0
  30. apsimo_hostworker-0.2.0/tests/test_client.py +319 -0
  31. apsimo_hostworker-0.2.0/tests/test_conformance.py +178 -0
  32. apsimo_hostworker-0.2.0/tests/test_contract.py +148 -0
  33. apsimo_hostworker-0.2.0/tests/test_gate.py +446 -0
  34. apsimo_hostworker-0.2.0/tests/test_intent.py +132 -0
  35. apsimo_hostworker-0.2.0/tests/test_sqlite_store.py +164 -0
  36. apsimo_hostworker-0.2.0/tests/test_worker.py +509 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aevonix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: apsimo-hostworker
3
+ Version: 0.2.0
4
+ Summary: Stateless, stdlib-only core for governed Apsimo host workers
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0; extra == "dev"
11
+ Dynamic: license-file
12
+
13
+ # Apsimo host worker
14
+
15
+ The stdlib-only action contract, capability catalog, execution ledger and
16
+ conformance checks used by Apsimo host adapters. This package has no sidecar or
17
+ web-server dependency.
18
+
19
+ Install `apsimo-hostworker==0.2.0` in a fresh environment and import
20
+ `apsimo_hostworker`. Legacy `colony_hostworker` imports resolve to the same
21
+ module objects. Existing action names and serialized contract identifiers are
22
+ preserved so pending actions and their digests remain valid.
23
+
24
+ Run `python -m apsimo_hostworker.conformance` to check a host implementation.
@@ -0,0 +1,12 @@
1
+ # Apsimo host worker
2
+
3
+ The stdlib-only action contract, capability catalog, execution ledger and
4
+ conformance checks used by Apsimo host adapters. This package has no sidecar or
5
+ web-server dependency.
6
+
7
+ Install `apsimo-hostworker==0.2.0` in a fresh environment and import
8
+ `apsimo_hostworker`. Legacy `colony_hostworker` imports resolve to the same
9
+ module objects. Existing action names and serialized contract identifiers are
10
+ preserved so pending actions and their digests remain valid.
11
+
12
+ Run `python -m apsimo_hostworker.conformance` to check a host implementation.
@@ -0,0 +1,134 @@
1
+ """Stdlib-only core for governed Colony host workers.
2
+
3
+ Phase A of the host-worker extraction: the wire contract, the governed tool
4
+ catalog, ``HermesToolActionIntentV1`` validation, and the approval-gate
5
+ invariant core. Phase B: the loopback execution client, the dispatch
6
+ admission, the :class:`~apsimo_hostworker.store.ActionStore` protocol with
7
+ its documented transactional invariants, the reference SQLite store, the
8
+ one-mutation worker, and the executable store conformance suite
9
+ (:mod:`apsimo_hostworker.conformance`). This distribution must never
10
+ import FastAPI or ``apsimo`` — see the design rule in
11
+ :mod:`apsimo_hostworker.contract`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .admission import (
17
+ DispatchAdmission,
18
+ DispatchAdmissionError,
19
+ FileDispatchAdmission,
20
+ sqlite_database_identity,
21
+ )
22
+ from .catalog import (
23
+ ACTION_TOOL_NAMES,
24
+ GRANT_AUTHORIZABLE_TOOL_NAMES,
25
+ NON_GRANTABLE_TOOL_NAMES,
26
+ TOOL_CATALOG,
27
+ ToolCatalogError,
28
+ ToolSpec,
29
+ validate_tool_args,
30
+ )
31
+ from .client import (
32
+ ClientCredential,
33
+ GovernedActionClient,
34
+ GovernedActionClientError,
35
+ WORKER_PRINCIPAL,
36
+ build_no_redirect_opener,
37
+ )
38
+ from .contract import (
39
+ GovernedContractError,
40
+ canonical_json_ascii,
41
+ canonical_json_utf8,
42
+ sha256_json_ascii,
43
+ sha256_json_utf8,
44
+ )
45
+ from .gate import (
46
+ BOUNDED_GRANT_SHAPE,
47
+ DEFAULT_REGISTRY,
48
+ GRANT_BINDING_METHOD,
49
+ GRANT_UNLIMITED_SENTINEL,
50
+ GateAuthorization,
51
+ MESSAGE_DELIVERY_SHAPE,
52
+ OwnerGateError,
53
+ ProvenanceShape,
54
+ ProvenanceShapeError,
55
+ ProvenanceShapeRegistry,
56
+ assert_dispatchable,
57
+ default_registry,
58
+ validate_owner_gate,
59
+ )
60
+ from .intent import HermesActionIntentError, HermesToolActionIntentV1
61
+ from .sqlite_store import SqliteActionStore
62
+ from .store import (
63
+ ActionIdempotencyConflict,
64
+ ActionLeaseConflict,
65
+ ActionNotFound,
66
+ ActionStore,
67
+ ActionStoreError,
68
+ ActionTransitionError,
69
+ )
70
+ from .worker import (
71
+ GovernedActionWorker,
72
+ GovernedActionWorkerError,
73
+ build_execution_request,
74
+ validate_execution_result,
75
+ )
76
+
77
+ try: # single source of truth: the installed distribution metadata
78
+ from importlib.metadata import PackageNotFoundError, version as _dist_version
79
+ try:
80
+ __version__ = _dist_version("apsimo-hostworker")
81
+ except PackageNotFoundError: # running from a source tree, not installed
82
+ __version__ = "0.2.0"
83
+ except ImportError: # pragma: no cover
84
+ __version__ = "0.2.0"
85
+
86
+ __all__ = (
87
+ "ACTION_TOOL_NAMES",
88
+ "ActionIdempotencyConflict",
89
+ "ActionLeaseConflict",
90
+ "ActionNotFound",
91
+ "ActionStore",
92
+ "ActionStoreError",
93
+ "ActionTransitionError",
94
+ "BOUNDED_GRANT_SHAPE",
95
+ "ClientCredential",
96
+ "DEFAULT_REGISTRY",
97
+ "DispatchAdmission",
98
+ "DispatchAdmissionError",
99
+ "FileDispatchAdmission",
100
+ "GRANT_AUTHORIZABLE_TOOL_NAMES",
101
+ "GRANT_BINDING_METHOD",
102
+ "GRANT_UNLIMITED_SENTINEL",
103
+ "GateAuthorization",
104
+ "GovernedActionClient",
105
+ "GovernedActionClientError",
106
+ "GovernedActionWorker",
107
+ "GovernedActionWorkerError",
108
+ "GovernedContractError",
109
+ "HermesActionIntentError",
110
+ "HermesToolActionIntentV1",
111
+ "MESSAGE_DELIVERY_SHAPE",
112
+ "NON_GRANTABLE_TOOL_NAMES",
113
+ "OwnerGateError",
114
+ "ProvenanceShape",
115
+ "ProvenanceShapeError",
116
+ "ProvenanceShapeRegistry",
117
+ "SqliteActionStore",
118
+ "TOOL_CATALOG",
119
+ "ToolCatalogError",
120
+ "ToolSpec",
121
+ "WORKER_PRINCIPAL",
122
+ "assert_dispatchable",
123
+ "build_execution_request",
124
+ "build_no_redirect_opener",
125
+ "canonical_json_ascii",
126
+ "canonical_json_utf8",
127
+ "default_registry",
128
+ "sha256_json_ascii",
129
+ "sha256_json_utf8",
130
+ "sqlite_database_identity",
131
+ "validate_execution_result",
132
+ "validate_owner_gate",
133
+ "validate_tool_args",
134
+ )
@@ -0,0 +1,267 @@
1
+ """Owner-only file and loopback-origin primitives for host workers.
2
+
3
+ Everything here is stdlib-only, side-effect-free beyond reading the named
4
+ file, and deliberately paranoid: these primitives sit under the credential
5
+ loader (:mod:`apsimo_hostworker.client`) and the dispatch-admission check
6
+ (:mod:`apsimo_hostworker.admission`), where a symlink race, a
7
+ group-readable secret, or an over-large document must fail closed rather
8
+ than degrade.
9
+
10
+ The bounded JSON reader is also the only JSON decoder the client uses for
11
+ network responses: it refuses duplicate object keys, non-finite numbers,
12
+ oversized integers, control characters, and unbounded nesting before any
13
+ value reaches a validator.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ipaddress
19
+ import json
20
+ import math
21
+ import os
22
+ import stat
23
+ import urllib.parse
24
+ from pathlib import Path
25
+ from typing import Any, Mapping
26
+
27
+ PRIVATE_DOCUMENT_MAX_BYTES = 16 * 1024
28
+
29
+
30
+ class PrivateIOError(RuntimeError):
31
+ """An owner-only file or origin failed its safety contract."""
32
+
33
+
34
+ def strict_json_bytes(raw: bytes, *, maximum: int, error=PrivateIOError) -> Any:
35
+ """Decode one bounded finite JSON value without duplicate object keys."""
36
+
37
+ if not isinstance(raw, bytes) or len(raw) > maximum:
38
+ raise error("JSON document exceeds its safety bound")
39
+
40
+ def pairs(values):
41
+ result = {}
42
+ for key, value in values:
43
+ if key in result:
44
+ raise error("JSON object keys must be unique")
45
+ result[key] = value
46
+ return result
47
+
48
+ def constant(_value):
49
+ raise error("JSON numbers must be finite")
50
+
51
+ def integer(value):
52
+ if len(value.lstrip("-")) > 19:
53
+ raise error("JSON integer exceeds its safety bound")
54
+ parsed = int(value)
55
+ if abs(parsed) > (1 << 63) - 1:
56
+ raise error("JSON integer exceeds its safety bound")
57
+ return parsed
58
+
59
+ def floating(value):
60
+ parsed = float(value)
61
+ if not math.isfinite(parsed):
62
+ raise error("JSON numbers must be finite")
63
+ return parsed
64
+
65
+ try:
66
+ value = json.loads(
67
+ raw.decode("utf-8", errors="strict"),
68
+ object_pairs_hook=pairs,
69
+ parse_constant=constant,
70
+ parse_int=integer,
71
+ parse_float=floating,
72
+ )
73
+ except error:
74
+ raise
75
+ except (UnicodeError, ValueError, TypeError, OverflowError, RecursionError) as exc:
76
+ raise error("JSON document is malformed") from exc
77
+
78
+ stack = [(value, 0)]
79
+ count = 0
80
+ while stack:
81
+ item, depth = stack.pop()
82
+ count += 1
83
+ if count > 1024 or depth > 16:
84
+ raise error("JSON document is too complex")
85
+ if isinstance(item, str):
86
+ if len(item) > 8192 or any(
87
+ ord(character) < 0x20 or ord(character) == 0x7F
88
+ for character in item
89
+ ):
90
+ raise error("JSON text is unsafe")
91
+ try:
92
+ item.encode("utf-8")
93
+ except UnicodeError as exc:
94
+ raise error("JSON text is not UTF-8") from exc
95
+ elif isinstance(item, Mapping):
96
+ stack.extend((key, depth + 1) for key in item)
97
+ stack.extend((child, depth + 1) for child in item.values())
98
+ elif isinstance(item, list):
99
+ stack.extend((child, depth + 1) for child in item)
100
+ elif item is not None and not isinstance(item, (bool, int, float)):
101
+ raise error("JSON document contains an unsafe value")
102
+ return value
103
+
104
+
105
+ def safe_ancestry(
106
+ path: Path, *, label: str, error=PrivateIOError
107
+ ) -> tuple[tuple[int, int, int], ...]:
108
+ """Attest every existing ancestor without resolving through a symlink."""
109
+
110
+ chain = []
111
+ cursor = path
112
+ while True:
113
+ chain.append(cursor)
114
+ if cursor.parent == cursor:
115
+ break
116
+ cursor = cursor.parent
117
+ identities = []
118
+ try:
119
+ for candidate in reversed(chain):
120
+ info = candidate.lstat()
121
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
122
+ raise error("%s path ancestry is unsafe" % label)
123
+ identities.append((info.st_dev, info.st_ino, info.st_mode))
124
+ except error:
125
+ raise
126
+ except OSError as exc:
127
+ raise error("%s path ancestry is unavailable" % label) from exc
128
+ return tuple(identities)
129
+
130
+
131
+ def read_private_json(
132
+ path: str, *, label: str, error=PrivateIOError
133
+ ) -> tuple[Path, bytes, Any]:
134
+ """Read an owner-only mode-0600 regular file without racing a symlink.
135
+
136
+ The file must be a regular file owned by the calling user with mode
137
+ exactly ``0600``, at most :data:`PRIVATE_DOCUMENT_MAX_BYTES` long, whose
138
+ identity (device, inode, size, mtime) and whole directory ancestry are
139
+ unchanged across the read. Anything else fails closed.
140
+ """
141
+
142
+ configured = str(path or "").strip()
143
+ if not configured:
144
+ raise error("%s file is not configured" % label)
145
+ target = Path(os.path.abspath(os.path.expanduser(configured)))
146
+ if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_CLOEXEC"):
147
+ raise error("private file loading is unsupported")
148
+ descriptor = None
149
+ try:
150
+ ancestry_before = safe_ancestry(target.parent, label=label, error=error)
151
+ initial = target.lstat()
152
+ if stat.S_ISLNK(initial.st_mode):
153
+ raise error("%s must not be a symlink" % label)
154
+ descriptor = os.open(target, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
155
+ before = os.fstat(descriptor)
156
+ if (
157
+ not stat.S_ISREG(before.st_mode)
158
+ or stat.S_IMODE(before.st_mode) != 0o600
159
+ or (hasattr(os, "geteuid") and before.st_uid != os.geteuid())
160
+ or before.st_size < 0
161
+ or before.st_size > PRIVATE_DOCUMENT_MAX_BYTES
162
+ or (initial.st_dev, initial.st_ino) != (before.st_dev, before.st_ino)
163
+ ):
164
+ raise error("%s must be an owned mode-0600 regular file" % label)
165
+ collected = bytearray()
166
+ while len(collected) <= PRIVATE_DOCUMENT_MAX_BYTES:
167
+ chunk = os.read(
168
+ descriptor,
169
+ min(4096, PRIVATE_DOCUMENT_MAX_BYTES + 1 - len(collected)),
170
+ )
171
+ if not chunk:
172
+ break
173
+ collected.extend(chunk)
174
+ after = os.fstat(descriptor)
175
+ current = target.lstat()
176
+ ancestry_after = safe_ancestry(target.parent, label=label, error=error)
177
+ stable = (
178
+ before.st_dev,
179
+ before.st_ino,
180
+ before.st_uid,
181
+ before.st_mode,
182
+ before.st_size,
183
+ before.st_mtime_ns,
184
+ ) == (
185
+ after.st_dev,
186
+ after.st_ino,
187
+ after.st_uid,
188
+ after.st_mode,
189
+ after.st_size,
190
+ after.st_mtime_ns,
191
+ )
192
+ if (
193
+ len(collected) > PRIVATE_DOCUMENT_MAX_BYTES
194
+ or len(collected) != before.st_size
195
+ or not stable
196
+ or ancestry_before != ancestry_after
197
+ or (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino)
198
+ ):
199
+ raise error("%s changed while being read" % label)
200
+ raw = bytes(collected)
201
+ except error:
202
+ raise
203
+ except OSError as exc:
204
+ raise error("%s file is unavailable" % label) from exc
205
+ finally:
206
+ if descriptor is not None:
207
+ os.close(descriptor)
208
+ return (
209
+ target,
210
+ raw,
211
+ strict_json_bytes(raw, maximum=PRIVATE_DOCUMENT_MAX_BYTES, error=error),
212
+ )
213
+
214
+
215
+ def loopback_origin(value: str, *, error=PrivateIOError) -> str:
216
+ """Return ``value`` iff it is one canonical loopback HTTP(S) origin.
217
+
218
+ Exactly ``scheme://host[:port]`` with a loopback host: no path, query,
219
+ fragment, userinfo, whitespace, or non-printable bytes. Everything a
220
+ governed-action client or admission may talk to must pass this, so a
221
+ configuration mistake can never point owner-authorized mutations at a
222
+ remote host.
223
+ """
224
+
225
+ raw = str(value or "")
226
+ if (
227
+ not raw
228
+ or raw != raw.strip()
229
+ or any(ord(character) < 0x21 or ord(character) > 0x7E for character in raw)
230
+ ):
231
+ raise error("service origin is invalid")
232
+ try:
233
+ parsed = urllib.parse.urlsplit(raw)
234
+ port = parsed.port
235
+ except ValueError as exc:
236
+ raise error("service origin is invalid") from exc
237
+ if (
238
+ parsed.scheme not in ("http", "https")
239
+ or not parsed.hostname
240
+ or parsed.username is not None
241
+ or parsed.password is not None
242
+ or parsed.path
243
+ or parsed.query
244
+ or parsed.fragment
245
+ or not parsed.netloc
246
+ ):
247
+ raise error("service origin must be one loopback origin")
248
+ hostname = parsed.hostname.lower()
249
+ try:
250
+ loopback = ipaddress.ip_address(hostname).is_loopback
251
+ except ValueError:
252
+ loopback = hostname == "localhost"
253
+ if not loopback or (port is not None and not 1 <= port <= 65535):
254
+ raise error("service origin must be loopback")
255
+ if urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", "")) != raw:
256
+ raise error("service origin must be canonical")
257
+ return raw
258
+
259
+
260
+ __all__ = (
261
+ "PRIVATE_DOCUMENT_MAX_BYTES",
262
+ "PrivateIOError",
263
+ "loopback_origin",
264
+ "read_private_json",
265
+ "safe_ancestry",
266
+ "strict_json_bytes",
267
+ )