Expose tunnel namespaces on IPC endpoints

Make `TCPAddress` and `UDSAddress` explicitly satisfy
`Address.namespace`, then retain each original listener declaration
beside its peeled, resolved transport address.

Deats,
- remove `TunnelledAddress`'s attribute fallback
- add required `Endpoint.declared_addr` metadata
- report declaration namespaces in endpoint/server formatting
- preserve concrete `Endpoint.addr` for transport reflection
- cover plain and tunneled namespace visibility

Prompt-IO: ai/prompt-io/opencode/20260822T032520Z_d35c802b_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Gud Boi 2026-08-21 23:57:24 -04:00
parent d35c802b1c
commit 29141f0b71
9 changed files with 146 additions and 35 deletions

View File

@ -0,0 +1,44 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-22T03:25:20Z
git_ref: d35c802b
scope: code
substantive: true
raw_file: 20260822T032520Z_d35c802b_prompt_io.raw.md
---
## Prompt
The human reported the explicit WireGuard verification commit done,
asked the agent to check it, and authorized the next incremental
Layer C change.
## Response summary
Verified commit `d35c802b`, made plain transport namespace behavior
explicit, and retained each original listener declaration beside its
peeled transport address. Endpoint and server diagnostics can now
report a tunnel's namespace without violating the transport boundary.
## Files changed
- `tractor/ipc/_tcp.py` - explicit plain-address namespace property.
- `tractor/ipc/_uds.py` - explicit plain-address namespace property.
- `tractor/discovery/_tunnel.py` - direct overlay namespace delegation.
- `tractor/ipc/_server.py` - retained declaration and namespace
diagnostics.
- `tests/discovery/test_tunnelled_addr.py` - plain and tunnel namespace
behavior.
- `tests/ipc/test_server_tunnel_boundary.py` - declaration retention
and diagnostic coverage.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - concrete endpoint
boundary contract.
## Human edits
The human selected continued incremental implementation after
reviewing and committing the preceding verification layer. The agent
implemented this dependency-ordered namespace slice; no direct manual
edits or follow-up corrections were observed.

View File

@ -0,0 +1,29 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-22T03:25:20Z
git_ref: d35c802b
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - retain endpoint namespace declarations
The human reported the explicit WireGuard verification commit done,
asked for it to be checked, and authorized the next incremental
change.
> `git diff HEAD~1..HEAD -- tractor/ipc/_tcp.py tractor/ipc/_uds.py tractor/discovery/_tunnel.py tractor/ipc/_server.py tests/discovery/test_tunnelled_addr.py tests/ipc/test_server_tunnel_boundary.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
Confirmed commit `d35c802b` and a clean worktree, then implemented the
smallest dependency-ordered Layer C slice. Plain TCP and UDS addresses
now explicitly report no namespace, allowing `TunnelledAddress` to
delegate without an attribute fallback.
Added required `Endpoint.declared_addr` metadata beside the peeled,
resolved `Endpoint.addr`. Endpoint and server diagnostics expose the
declaration's namespace without passing a tunnel wrapper into
transport reflection. Updated the Layer C plan and tests for plain,
tunnelled, endpoint and server namespace behavior.
Ruff passed. Focused namespace tests passed 13 tests; combined
discovery and IPC coverage passed 101 tests with 2 xpasses.

View File

@ -465,6 +465,10 @@ contract open. A concrete transport call returns a concrete overlay;
a declaration-level call may replace the overlay and return a new a declaration-level call may replace the overlay and return a new
`TunnelledAddress`. In either case wrappers remain until the final `TunnelledAddress`. In either case wrappers remain until the final
transport bind/dial boundary, where `strip_tunnels()` is mandatory. transport bind/dial boundary, where `strip_tunnels()` is mandatory.
At the listener boundary, keep the split explicit:
`Endpoint.addr` is the peeled concrete address used for transport
reflection, while `Endpoint.declared_addr` retains the original
wrapper for namespace diagnostics and later bindspace orchestration.
Per-platform provisioning still composes one resource context per Per-platform provisioning still composes one resource context per
tunnel/bindspace layer: tunnel/bindspace layer:
@ -501,16 +505,13 @@ required local provisioning/ownership differs (§5.4).
- `TunnelledAddress.namespace``(kind, id)` e.g. - `TunnelledAddress.namespace``(kind, id)` e.g.
`('netns', 'tractor-wg0')`. `('netns', 'tractor-wg0')`.
- **and** the existing backends should implement it as `None` - existing plain backends implement it explicitly as `None`, so the
explicitly (they currently just don't define it), so the Protocol does not lie and tunnel delegation needs no `getattr()`
Protocol stops lying. fallback.
- consumers to audit: nothing reads `.namespace` today — so - `Endpoint.namespace` reads the retained declaration rather than its
adding it is safe, but the *point* is that peeled transport addr; both `Endpoint.pformat()` and
`Endpoint`/`Server.pformat()` should start showing it (there's `Server.pformat()` expose that value as the cheapest proof the layer
already a `# !TODO, always be ns aware!` + is wired.
`f'|_netns: {netns}\n'` placeholder sitting in
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
the cheapest possible proof the layer is wired.
Use `github/ns_aware@e4688cad` as prototype evidence, not code to Use `github/ns_aware@e4688cad` as prototype evidence, not code to
cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and

View File

@ -27,6 +27,7 @@ from tractor.discovery._addr import (
wrap_address, wrap_address,
) )
from tractor.ipc._tcp import TCPAddress from tractor.ipc._tcp import TCPAddress
from tractor.ipc._uds import UDSAddress
# a valid-looking std-base64 `wg(8)` pubkey (32B -> 44 chars) # a valid-looking std-base64 `wg(8)` pubkey (32B -> 44 chars)
@ -174,16 +175,13 @@ def test_namespace_comes_from_the_tunnel(
overlay: TCPAddress, overlay: TCPAddress,
): ):
''' '''
First real consumer of `Address.namespace`, spec'd in the Plain transport addresses explicitly select no namespace, while
protocol since day one and implemented by no backend. a tunnel can select one for the same concrete overlay.
''' '''
# XXX, "no backend implements it" is literal — the member uds_addr: UDSAddress = UDSAddress('/tmp', 'tractor-test.sock')
# isn't even declared, so this is `AttributeError` not `None`. assert overlay.namespace is None
# This assert is the guard: when a backend finally declares assert uds_addr.namespace is None
# `.namespace`, it fails and the `getattr()` fallback in
# `TunnelledAddress.namespace` can go.
assert not hasattr(overlay, 'namespace')
no_ns = TunnelledAddress( no_ns = TunnelledAddress(
overlay=overlay, overlay=overlay,

View File

@ -18,14 +18,15 @@ from tractor.ipc._tcp import TCPAddress
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc=' _PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
def test_server_peels_before_endpoint_construction(): def test_server_peels_before_endpoint_construction() -> None:
''' '''
`Endpoint.start_listener()` reflects on its address's declaring `Endpoint.start_listener()` reflects on its address's declaring
module, so retaining a tunnel wrapper there selects `._tunnel` module, so retaining a tunnel wrapper there selects `._tunnel`
instead of the TCP backend. Start a real listener from the instead of the TCP backend. Start a real listener from the
wrapper, assert the resulting `Endpoint` contains only a resolved wrapper, assert the resulting `Endpoint` contains only a resolved
`TCPAddress`, and prove the original declaration still carries `TCPAddress`. Prove `Endpoint.declared_addr` still retains the
its tunnel spec for the future bindspace lifecycle. original tunnel namespace for diagnostics and the future
bindspace lifecycle.
''' '''
overlay = TCPAddress('127.0.0.1', 0) overlay = TCPAddress('127.0.0.1', 0)
@ -34,6 +35,7 @@ def test_server_peels_before_endpoint_construction():
tunnel=WGTunnelSpec( tunnel=WGTunnelSpec(
peer_pubkey=_PUBKEY, peer_pubkey=_PUBKEY,
bearer=('192.168.1.50', 51820), bearer=('192.168.1.50', 51820),
netns='actor-net',
), ),
) )
@ -50,6 +52,16 @@ def test_server_peels_before_endpoint_construction():
assert host == overlay.unwrap()[1] assert host == overlay.unwrap()[1]
assert port > 0 assert port > 0
assert endpoint.addr is not tunnelled assert endpoint.addr is not tunnelled
assert endpoint.declared_addr is tunnelled
namespace: tuple[str, str] = ('netns', 'actor-net')
assert endpoint.namespace == namespace
endpoint_repr: str = endpoint.pformat()
server_repr: str = server.pformat()
expected_namespace: str = f'namespace: {namespace!r}'
assert expected_namespace in endpoint_repr
assert ' |_namespaces:' in server_repr
assert 'netns' in server_repr
assert 'actor-net' in server_repr
assert tunnels_of(tunnelled) == ( assert tunnels_of(tunnelled) == (
tunnelled.tunnel, tunnelled.tunnel,
) )

View File

@ -426,20 +426,9 @@ class TunnelledAddress(
''' '''
The tunnel's netns, when it declares one. The tunnel's netns, when it declares one.
This is the first real consumer of `Address.namespace`,
spec'd in the `Address` protocol since day one and
implemented by no backend.
XXX NOTE, "implemented by no backend" is literal: neither
`TCPAddress` nor `UDSAddress` defines `.namespace` at all,
so a plain attr access on an overlay raises
`AttributeError` rather than yielding `None`. Hence the
`getattr()` drop it once the backends actually declare
the member.
''' '''
if (netns := self.tunnel.netns) is None: if (netns := self.tunnel.netns) is None:
return getattr(self.overlay, 'namespace', None) return self.overlay.namespace
return ('netns', netns) return ('netns', netns)

View File

@ -625,6 +625,7 @@ class Endpoint(Struct):
''' '''
addr: Address addr: Address
declared_addr: Address|TunnelledAddress
listen_tn: Nursery listen_tn: Nursery
stream_handler_tn: Nursery|None = None stream_handler_tn: Nursery|None = None
@ -639,15 +640,27 @@ class Endpoint(Struct):
MsgTransport, # handle to encoded-msg transport stream MsgTransport, # handle to encoded-msg transport stream
] = {} ] = {}
@property
def namespace(self) -> tuple[str, str|int]|None:
'''
Return the original address declaration's namespace.
`Endpoint.addr` is peeled to its concrete transport before
listener reflection, so `.declared_addr` retains bindspace
metadata for diagnostics and later provisioning.
'''
return self.declared_addr.namespace
def pformat( def pformat(
self, self,
indent: int = 0, indent: int = 0,
privates: bool = False, privates: bool = False,
) -> str: ) -> str:
type_repr: str = type(self).__name__ type_repr: str = type(self).__name__
namespace: tuple[str, str|int]|None = self.namespace
fmtstr: str = ( fmtstr: str = (
# !TODO, always be ns aware! f' |.namespace: {namespace!r}\n'
# f'|_netns: {netns}\n'
f' |.addr: {self.addr!r}\n' f' |.addr: {self.addr!r}\n'
f' |_peers: {len(self.peer_tpts)}\n' f' |_peers: {len(self.peer_tpts)}\n'
) )
@ -925,9 +938,17 @@ class Server(Struct):
ep.addr for ep in eps ep.addr for ep in eps
] ]
repr_eps: str = ppfmt(addrs) repr_eps: str = ppfmt(addrs)
namespaces: list[
tuple[str, str|int]|None
] = []
ep: Endpoint
for ep in eps:
namespaces.append(ep.namespace)
repr_namespaces: str = ppfmt(namespaces)
fmtstr += ( fmtstr += (
f' |_endpoints: {repr_eps}\n' f' |_endpoints: {repr_eps}\n'
f' |_namespaces: {repr_namespaces}\n'
# ^TODO? how to indent closing ']'.. # ^TODO? how to indent closing ']'..
) )
@ -1080,6 +1101,7 @@ async def _serve_ipc_eps(
addr=addr, addr=addr,
listen_tn=listen_tn, listen_tn=listen_tn,
stream_handler_tn=stream_handler_tn, stream_handler_tn=stream_handler_tn,
declared_addr=declared_addr,
) )
try: try:
ep_sclang: str = nest_from_op( ep_sclang: str = nest_from_op(

View File

@ -101,6 +101,14 @@ class TCPAddress(
def bindspace(self) -> str: def bindspace(self) -> str:
return self._host return self._host
@property
def namespace(self) -> None:
'''
Report that plain TCP uses the process's current namespace.
'''
return None
@property @property
def domain(self) -> str: def domain(self) -> str:
return self._host return self._host

View File

@ -131,6 +131,14 @@ class UDSAddress(
self.def_bindspace self.def_bindspace
) )
@property
def namespace(self) -> None:
'''
Report that plain UDS uses the process's current namespace.
'''
return None
@property @property
def sockpath(self) -> Path: def sockpath(self) -> Path:
return Path(self.bindspace) / self.filename return Path(self.bindspace) / self.filename