From b9dca728d93d9dcf0038e254b15fb24d21e6fbdc Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 25 Aug 2026 00:50:51 -0400 Subject: [PATCH] Add bindspace capability models Separate serializable `BindspaceSpec` and `BindspaceIdentity` values from a process-local `BindspaceHandle` carrying FD and ownership authority. Deats, - add global `ProcessLocal` wire guards for local handle structs - derive valid kinds and ownership from their `Literal` aliases - require a positive inode while keeping the mutable name optional - pin supplied FDs to identity inodes with `fstat()` - cover round trips, nested encoding and stale capabilities Caught-during: review remediation Found-via: `/run-tests` test_bindspace_handle_pins_local_capability Prompt-IO: ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- .../20260822T042026Z_29141f0b_prompt_io.md | 64 ++++++ ...20260822T042026Z_29141f0b_prompt_io.raw.md | 27 +++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 26 ++- tests/discovery/test_bindspace.py | 201 ++++++++++++++++++ tests/msg/test_process_local.py | 57 +++++ tractor/discovery/__init__.py | 7 + tractor/discovery/_bindspace.py | 187 ++++++++++++++++ tractor/msg/__init__.py | 3 + tractor/msg/_local.py | 54 +++++ 9 files changed, 616 insertions(+), 10 deletions(-) create mode 100644 ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.raw.md create mode 100644 tests/discovery/test_bindspace.py create mode 100644 tests/msg/test_process_local.py create mode 100644 tractor/discovery/_bindspace.py create mode 100644 tractor/msg/_local.py diff --git a/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md new file mode 100644 index 00000000..0148f31d --- /dev/null +++ b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md @@ -0,0 +1,64 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-22T04:20:26Z +git_ref: 29141f0b +scope: code +substantive: true +raw_file: 20260822T042026Z_29141f0b_prompt_io.raw.md +--- + +## Prompt + +After committing endpoint namespace visibility, the human authorized +continued Layer C implementation. + +During staged review, the human requested Literal-derived validation, +ownership documentation, stable-inode clarification, explicit +non-serialization rationale and consolidated invalid-model tests. + +## Response summary + +Added the foundational bindspace model: serializable declarations and +stable identities are separated from a process-local live capability. +The handle validates names, ownership and FD identity. A global +`ProcessLocal` sentinel blocks default encoding while retaining +msgspec struct behavior. +Review fixes require a positive inode for every realized netns, +derive runtime choices from the Literal aliases and clarify that an FD +integer is not transferable capability authority. +The human then clarified that msgspec structs are useful generic +storage independently of serialization policy, so the live handle now +uses a struct while remaining process-local by contract. +The human first selected an opaque FD wrapper, then recognized that +future process-local handles need the same guard and directed a global +marker under `tractor.msg` instead. + +## Files changed + +- `tractor/discovery/_bindspace.py` - declaration, identity and live + capability models. +- `tractor/discovery/__init__.py` - public bindspace exports. +- `tractor/msg/_local.py` - reusable process-local struct marker. +- `tractor/msg/__init__.py` - public `ProcessLocal` export. +- `tests/discovery/test_bindspace.py` - serialization and capability + invariant coverage. +- `tests/msg/test_process_local.py` - direct and nested wire rejection. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - concrete initial model + contract. + +## Human edits + +The human selected the foundational Layer C capability model, then +reviewed the staged patch and directed all eight follow-up changes: +remove distracting ownership variation, consolidate invalid-model +tests, derive containment checks from Literal aliases, require a +stable inode and explain why the live handle is not a msgspec struct. +The human subsequently corrected that last design choice: struct use +does not imply IPC authorization, so `BindspaceHandle` should retain +msgspec's generic storage benefits. The agent applied those +human-directed edits. The human first chose an opaque process-local FD +wrapper, then refined that decision to a reusable global marker under +`tractor.msg` for future local handles. No direct manual source edits +were observed. diff --git a/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.raw.md b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.raw.md new file mode 100644 index 00000000..8fb196f4 --- /dev/null +++ b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.raw.md @@ -0,0 +1,27 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-22T04:20:26Z +git_ref: 29141f0b +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - model bindspace capabilities + +The human authorized the next incremental Layer C change after +committing endpoint namespace visibility. + +> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Added frozen, serializable `BindspaceSpec` and +`BindspaceIdentity` models for the initial Linux netns kind. Added a +plain slotted `BindspaceHandle` which carries ownership and an optional +namespace FD without being serializable by default msgspec encoding. + +The live handle validates declaration/identity kind and name, ownership +values, non-negative FDs and FD/inode correspondence. Serializable +models reject empty identities and kinds without implementations. + +Added round-trip, non-serialization, identity-mismatch, empty-identity +and unsupported-kind tests. Ruff passed; discovery plus +message-serialization coverage passed 125 tests with 2 xpasses. diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 2b8ad1c6..582ef70b 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -413,11 +413,11 @@ class BindspaceSpec(msgspec.Struct, frozen=True): class BindspaceIdentity(msgspec.Struct, frozen=True): '''Stable identity of the realized platform resource.''' kind: str - key: str|None - inode: int|None # Linux namespace identity + key: str|None # mutable name, absent after unlink + inode: int # stable Linux namespace identity -class BindspaceHandle: +class BindspaceHandle(ProcessLocal): '''Scoped, non-serializable capability for one live bindspace.''' spec: BindspaceSpec identity: BindspaceIdentity @@ -437,13 +437,19 @@ async def open_bindspace( ''' ``` -The exact field set remains design work; the required split does not: -`BindspaceSpec` crosses config/spawn serialization, while -`BindspaceHandle` contains live OS resources (especially an open -namespace FD), pins identity/lifetime, and must never cross msgpack. -An FD is a stronger capability than a namespace name: it avoids -name-resolution TOCTOU, survives rename/unlink, and identifies the -exact namespace the parent provisioned. +The initial model limits `BindspaceKind` to `netns` while preserving +the required lifetime split. `BindspaceSpec` and +`BindspaceIdentity` are frozen msgspec structs which cross +config/spawn serialization. `BindspaceHandle` also uses msgspec's +generic struct storage by inheriting the global +`tractor.msg.ProcessLocal` marker. Its hidden unsupported sentinel +blocks direct and nested default msgspec encoding without a recursive +IPC hot-path scan. The handle validates any supplied FD against +`BindspaceIdentity.inode`; explicit FD transfer belongs to the +supervisor bootstrap path. An FD avoids name-resolution TOCTOU, +survives rename/unlink, and identifies the exact namespace the parent +provisioned. Extend the kind/field union only when a second platform +resource is implemented. `open_bindspace()` is **not** an address factory and does not return a `TunnelledAddress`. At the declaration layer, listener allocation can diff --git a/tests/discovery/test_bindspace.py b/tests/discovery/test_bindspace.py new file mode 100644 index 00000000..ddebc915 --- /dev/null +++ b/tests/discovery/test_bindspace.py @@ -0,0 +1,201 @@ +''' +Bindspace declaration, identity and live-capability contracts. + +''' +from __future__ import annotations + +from pathlib import Path +from typing import BinaryIO + +import msgspec +import pytest + +from tractor.discovery import ( + BindspaceHandle, + BindspaceIdentity, + BindspaceOwnership, + BindspaceSpec, +) +from tractor.msg import ProcessLocal + + +def test_bindspace_declarations_roundtrip() -> None: + ''' + Spawn configuration and realized identity must cross actor IPC. + + Encode both frozen structs through msgpack and decode with their + concrete types, proving names and stable inode identity survive + without carrying any process-local capability state. + + ''' + values: tuple[ + BindspaceSpec|BindspaceIdentity, + ..., + ] = ( + BindspaceSpec(kind='netns', key='tractor-wg0'), + BindspaceIdentity( + kind='netns', + key='tractor-wg0', + inode=1234, + ), + ) + value: BindspaceSpec|BindspaceIdentity + for value in values: + encoded: bytes = msgspec.msgpack.encode(value) + decoded: BindspaceSpec|BindspaceIdentity = ( + msgspec.msgpack.decode( + encoded, + type=type(value), + ) + ) + assert decoded == value + + +def test_bindspace_handle_pins_local_capability( + tmp_path: Path, +) -> None: + ''' + A live handle pins one exact FD and realized identity. + + Open a stand-in platform handle, record its inode in the realized + identity and construct an owned capability. Prove the generic + msgspec struct retains that exact local state. Its ability to + encode ordinary fields is not authority to transfer the handle. + + ''' + token_path: Path = tmp_path / 'bindspace' + token_path.touch() + namespace_file: BinaryIO + with token_path.open('rb') as namespace_file: + namespace_fd: int = namespace_file.fileno() + inode: int = token_path.stat().st_ino + spec: BindspaceSpec = BindspaceSpec( + kind='netns', + key='tractor-wg0', + ) + identity: BindspaceIdentity = BindspaceIdentity( + kind='netns', + key='tractor-wg0', + inode=inode, + ) + handle: BindspaceHandle = BindspaceHandle( + spec=spec, + identity=identity, + namespace_fd=namespace_fd, + ownership='owned', + ) + + assert handle.spec is spec + assert handle.identity is identity + assert handle.namespace_fd == namespace_file.fileno() + assert handle.ownership == 'owned' + assert isinstance(handle, msgspec.Struct) + assert isinstance(handle, ProcessLocal) + with pytest.raises( + TypeError, + match='_ProcessLocalToken.*unsupported', + ): + msgspec.msgpack.encode(handle) + + +def test_bindspace_handle_rejects_mismatched_identity( + tmp_path: Path, +) -> None: + ''' + A name or inode mismatch would make a handle stale authority. + + Construct a requested named spec, then prove both a different + realized name and an inode not belonging to the supplied FD are + rejected before either can become a live capability. + + ''' + token_path: Path = tmp_path / 'bindspace' + token_path.touch() + spec: BindspaceSpec = BindspaceSpec( + kind='netns', + key='tractor-wg0', + ) + # Keep ownership and FD fixed so only identity changes below. + ownership: BindspaceOwnership = 'borrowed' + namespace_file: BinaryIO + with token_path.open('rb') as namespace_file: + namespace_fd: int = namespace_file.fileno() + wrong_name: BindspaceIdentity = BindspaceIdentity( + kind='netns', + key='other-wg', + inode=token_path.stat().st_ino, + ) + with pytest.raises( + ValueError, + match='Spec.key.*Identity.key', + ): + BindspaceHandle( + spec=spec, + identity=wrong_name, + namespace_fd=namespace_fd, + ownership=ownership, + ) + + wrong_inode: BindspaceIdentity = BindspaceIdentity( + kind='netns', + key='tractor-wg0', + inode=token_path.stat().st_ino + 1, + ) + with pytest.raises( + ValueError, + match='FD inode.*identity inode', + ): + BindspaceHandle( + spec=spec, + identity=wrong_inode, + namespace_fd=namespace_fd, + ownership=ownership, + ) + + +@pytest.mark.parametrize( + ('model', 'kwargs', 'match'), + ( + pytest.param( + BindspaceIdentity, + { + 'kind': 'netns', + 'key': None, + 'inode': None, + }, + 'must be a positive `int`', + id='identity-requires-inode', + ), + pytest.param( + BindspaceSpec, + {'kind': 'vrf'}, + 'Unsupported bindspace kind', + id='spec-rejects-kind', + ), + pytest.param( + BindspaceIdentity, + { + 'kind': 'vrf', + 'key': 'blue', + 'inode': 1234, + }, + 'Unsupported bindspace kind', + id='identity-rejects-kind', + ), + ), +) +def test_bindspace_models_reject_invalid_values( + model: type[BindspaceSpec]|type[BindspaceIdentity], + kwargs: dict[str, object], + match: str, +) -> None: + ''' + Direct msgspec construction does not enforce field annotations. + + Parameterize the missing stable inode and future, unimplemented + kinds. Prove neither serializable model can carry invalid + identity or provisioning instructions into spawn configuration. + + ''' + with pytest.raises(ValueError, match=match): + model(**kwargs) # type: ignore[arg-type] diff --git a/tests/msg/test_process_local.py b/tests/msg/test_process_local.py new file mode 100644 index 00000000..c8e220a2 --- /dev/null +++ b/tests/msg/test_process_local.py @@ -0,0 +1,57 @@ +''' +Process-local struct wire-encoding guards. + +''' +from __future__ import annotations + +import msgspec +import pytest + +from tractor.msg import ProcessLocal + + +class LocalHandle(ProcessLocal): + ''' + Minimal process-local struct used to exercise the global marker. + + ''' + resource_id: int + + +@pytest.mark.parametrize( + 'nested', + ( + pytest.param( + False, + id='direct', + ), + pytest.param( + True, + id='nested', + ), + ), +) +def test_process_local_rejects_default_encoding( + nested: bool, +) -> None: + ''' + Process-local values can appear directly or deep in a payload. + + Embed the same marked struct at both depths and prove msgspec's + normal traversal reaches the unsupported sentinel without a + tractor-specific recursive payload scan. + + ''' + handle: LocalHandle = LocalHandle(resource_id=1) + value: object = ( + {'nested': [handle]} + if nested + else handle + ) + + assert repr(handle) == 'LocalHandle(resource_id=1)' + with pytest.raises( + TypeError, + match='_ProcessLocalToken.*unsupported', + ): + msgspec.msgpack.encode(value) diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index cfa01cf5..a765cae9 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -24,6 +24,13 @@ Heavier submodules like ``._addr`` and ``._api`` are NOT imported here to avoid circular imports; use direct module paths for those. ''' +from ._bindspace import ( + BindspaceHandle as BindspaceHandle, + BindspaceIdentity as BindspaceIdentity, + BindspaceKind as BindspaceKind, + BindspaceOwnership as BindspaceOwnership, + BindspaceSpec as BindspaceSpec, +) from ._multiaddr import ( parse_endpoints as parse_endpoints, parse_maddr as parse_maddr, diff --git a/tractor/discovery/_bindspace.py b/tractor/discovery/_bindspace.py new file mode 100644 index 00000000..dcb3650c --- /dev/null +++ b/tractor/discovery/_bindspace.py @@ -0,0 +1,187 @@ +# tractor: structured concurrent "actors". +# Copyright 2018-eternity Tyler Goodlet. + +# This program is free software: you can redistribute it and/or +# modify it under the terms of the GNU Affero General Public License +# as published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see +# . +''' +Serializable bindspace declarations and live capability handles. + +''' +from __future__ import annotations + +import os +from typing import ( + get_args, + Literal, + TypeAlias, +) + +import msgspec + +from ..msg._local import ProcessLocal + + +BindspaceKind: TypeAlias = Literal[ + 'netns', +] +BindspaceOwnership: TypeAlias = Literal[ + 'owned', # manager tears down the resource after final release + 'borrowed', # manager leaves the pre-existing resource intact +] + + +def _validate_bindspace_kind( + kind: BindspaceKind, +) -> None: + ''' + Reject platform-resource kinds without an implementation. + + ''' + if kind not in get_args(BindspaceKind): + raise ValueError( + f'Unsupported bindspace kind: {kind!r}' + ) + + +class BindspaceSpec( + msgspec.Struct, + frozen=True, +): + ''' + Serializable declaration of one requested bindspace. + + ''' + kind: BindspaceKind + key: str|None = None + + def __post_init__(self) -> None: + ''' + Reject an empty platform-resource key. + + ''' + _validate_bindspace_kind(self.kind) + if self.key == '': + raise ValueError( + '`BindspaceSpec.key` must be non-empty or `None`!' + ) + + +class BindspaceIdentity( + msgspec.Struct, + frozen=True, +): + ''' + Serializable stable identity of one realized bindspace. + + `.key` is an optional, mutable namespace name. `.inode` is the + required kernel identity which remains stable after rename or + unlink. + + ''' + kind: BindspaceKind + key: str|None + inode: int + + def __post_init__(self) -> None: + ''' + Require a stable platform identity and an optional name. + + ''' + _validate_bindspace_kind(self.kind) + if self.key == '': + raise ValueError( + '`BindspaceIdentity.key` must be non-empty ' + 'or `None`!' + ) + if ( + type(self.inode) is not int + or + self.inode <= 0 + ): + raise ValueError( + '`BindspaceIdentity.inode` must be a positive `int`!' + ) + + +class BindspaceHandle( + ProcessLocal, +): + ''' + Process-local capability for one live realized bindspace. + + `ProcessLocal` provides compact typed storage plus a default + wire-encoding guard. Explicit FD transfer and handle construction + belong to the supervisor's spawn/bootstrap path. + + ''' + spec: BindspaceSpec + identity: BindspaceIdentity + namespace_fd: int|None + ownership: BindspaceOwnership + + def __post_init__(self) -> None: + ''' + Validate and retain one scoped bindspace capability. + + ''' + spec: BindspaceSpec = self.spec + identity: BindspaceIdentity = self.identity + namespace_fd: int|None = self.namespace_fd + ownership: BindspaceOwnership = self.ownership + + if spec.kind != identity.kind: + raise ValueError( + '`BindspaceSpec.kind` does not match ' + '`BindspaceIdentity.kind`!' + ) + if ( + spec.key is not None + and + spec.key != identity.key + ): + raise ValueError( + '`BindspaceSpec.key` does not match ' + '`BindspaceIdentity.key`!' + ) + if ownership not in get_args(BindspaceOwnership): + raise ValueError( + f'Invalid bindspace ownership: {ownership!r}' + ) + if namespace_fd is not None: + if ( + type(namespace_fd) is not int + or + namespace_fd < 0 + ): + raise ValueError( + '`namespace_fd` must be non-negative or `None`!' + ) + fd_inode: int = os.fstat(namespace_fd).st_ino + if identity.inode != fd_inode: + raise ValueError( + f'Namespace FD inode {fd_inode} does not match ' + f'identity inode {identity.inode}!' + ) + + def __repr__(self) -> str: + ''' + Render capability identity without dereferencing its FD. + + ''' + return ( + f'{type(self).__name__}(' + f'identity={self.identity!r}, ' + f'ownership={self.ownership!r}, ' + f'namespace_fd={self.namespace_fd!r})' + ) diff --git a/tractor/msg/__init__.py b/tractor/msg/__init__.py index 88220054..b1a1ce5c 100644 --- a/tractor/msg/__init__.py +++ b/tractor/msg/__init__.py @@ -27,6 +27,9 @@ from .ptr import ( from .pretty_struct import ( Struct as Struct, ) +from ._local import ( + ProcessLocal as ProcessLocal, +) from ._codec import ( _def_msgspec_codec as _def_msgspec_codec, _ctxvar_MsgCodec as _ctxvar_MsgCodec, diff --git a/tractor/msg/_local.py b/tractor/msg/_local.py new file mode 100644 index 00000000..1bdd8b49 --- /dev/null +++ b/tractor/msg/_local.py @@ -0,0 +1,54 @@ +# tractor: structured concurrent "actors". +# Copyright 2018-eternity Tyler Goodlet. + +# This program is free software: you can redistribute it and/or +# modify it under the terms of the GNU Affero General Public License +# as published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see +# . +''' +Markers for process-local values which must not cross actor IPC. + +''' +from __future__ import annotations + +import msgspec + + +class _ProcessLocalToken: + ''' + Unsupported msgspec value embedded in every `ProcessLocal`. + + ''' + __slots__ = () + + +_PROCESS_LOCAL_TOKEN: _ProcessLocalToken = _ProcessLocalToken() + + +class ProcessLocal( + msgspec.Struct, + kw_only=True, + repr_omit_defaults=True, +): + ''' + Generic struct marker which rejects default msgspec encoding. + + The hidden sentinel remains part of the encoded field set, so + msgspec encounters `_ProcessLocalToken` and raises `TypeError` + even when this value is nested inside another supported payload. + A custom encode hook may explicitly override that safeguard. + + Keyword-only fields let subclasses add required fields after the + marker's default sentinel. + + ''' + _process_local: _ProcessLocalToken = _PROCESS_LOCAL_TOKEN