Model bindspaces as scoped capabilities
Separate serializable bindspace declarations from live namespace identity, FDs, ownership and teardown resources. Require child namespace entry during spawn bootstrap, before actor runtime initialization, then distinguish listen/dial provisioning and owned/borrowed cleanup without encoding operation role into maddrs. Prompt-IO: ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))wkt/addr_unpacking
parent
dfad66a00a
commit
ba07e09d2a
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking-followup
|
||||||
|
timestamp: 2026-08-20T02:15:16Z
|
||||||
|
git_ref: dfad66a0
|
||||||
|
scope: docs
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260820T021516Z_dfad66a0_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human requested that the bindspace plan preserve the agreed
|
||||||
|
capability, spawn-bootstrap, endpoint-role, namespace augmentation,
|
||||||
|
random-address, and teardown semantics, using `github/ns_aware` as
|
||||||
|
prototype input.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Updated plan-03 and the shared backend contract to separate serializable
|
||||||
|
bindspace declarations from scoped live capabilities, make namespace
|
||||||
|
entry a pre-runtime spawn operation, keep maddr paths role-neutral, and
|
||||||
|
define listen/dial provisioning plus ownership-sensitive teardown.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - layer-C capability,
|
||||||
|
bootstrap, role, teardown, test, and risk model.
|
||||||
|
- `ai/tpt-backends/00_shared_backend_contract.md` - distinguish
|
||||||
|
transport bind selectors from process namespace lifecycle.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human supplied the core architecture: structured scoped
|
||||||
|
capabilities, spawn-time namespace entry, orthogonal namespace
|
||||||
|
augmentation, source/destination-dependent provisioning, and
|
||||||
|
role-dependent teardown. They also rejected premature assumptions about
|
||||||
|
`open_bindspace()` returning an address and requested grounding in the
|
||||||
|
existing namespace prototype. The agent translated those decisions into
|
||||||
|
the plan text; no direct manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-20T02:15:16Z
|
||||||
|
git_ref: dfad66a0
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - bindspace capability design
|
||||||
|
|
||||||
|
The human corrected the layer-C design around local network-stack
|
||||||
|
realization. They established that bindspace state should be both
|
||||||
|
structured and a scoped capability; namespace entry belongs in
|
||||||
|
subactor bootstrap; maddrs can describe source or destination network
|
||||||
|
paths while namespace selection augments them orthogonally; random
|
||||||
|
address and teardown behavior depend on operation role and ownership.
|
||||||
|
|
||||||
|
They directed comparison with the prototype on `github/ns_aware` and
|
||||||
|
requested these decisions be preserved in the plan.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- ai/tpt-backends/03_wg_tunnel_bindspace.md ai/tpt-backends/00_shared_backend_contract.md`
|
||||||
|
|
||||||
|
Reworked layer C around serializable `BindspaceSpec`, stable
|
||||||
|
`BindspaceIdentity`, and scoped non-serializable `BindspaceHandle`
|
||||||
|
concepts. Namespace FDs pin identity and lifetime; parent/supervisor
|
||||||
|
provisioning transfers entry capability through spawn; the child enters
|
||||||
|
before runtime, channels, listeners, sockets, or worker threads and then
|
||||||
|
drops authority. Listen/dial roles and owned/borrowed teardown are
|
||||||
|
explicit, while maddrs remain role-neutral network-path declarations.
|
||||||
|
|
||||||
|
The shared backend contract now separates transport-level `.bindspace`
|
||||||
|
selectors from process namespace lifecycle. Added tests/risks for FD
|
||||||
|
identity, bootstrap ordering, privilege drop, role ownership, and
|
||||||
|
shared-resource teardown.
|
||||||
|
|
@ -112,7 +112,12 @@ Hard constraints learned from the existing two:
|
||||||
- **`.bindspace` semantics**: "the address' bindable space" —
|
- **`.bindspace` semantics**: "the address' bindable space" —
|
||||||
ip/host for `tcp`, the socket-file *directory* for `uds`. For
|
ip/host for `tcp`, the socket-file *directory* for `uds`. For
|
||||||
the new backends: the TIPC *scope* (§1 of plan 01), the iroh
|
the new backends: the TIPC *scope* (§1 of plan 01), the iroh
|
||||||
*ALPN + relay/discovery realm* (plan 02), the netns (plan 03).
|
*ALPN + relay/discovery realm* (plan 02). Do not overload this
|
||||||
|
transport-level bind selector with process namespace lifecycle.
|
||||||
|
Plan 03 augments an maddr/address declaration with a serializable
|
||||||
|
`BindspaceSpec` and a scoped, non-serializable `BindspaceHandle`;
|
||||||
|
the latter owns namespace identity/FD/lifetime and is consumed at
|
||||||
|
spawn bootstrap before a concrete address reaches transport bind.
|
||||||
`Address.namespace` is already spec'd in the Protocol as
|
`Address.namespace` is already spec'd in the Protocol as
|
||||||
"the if-available OS-specific network namespace key" and is
|
"the if-available OS-specific network namespace key" and is
|
||||||
currently unimplemented by both backends — plan 03 is the
|
currently unimplemented by both backends — plan 03 is the
|
||||||
|
|
|
||||||
|
|
@ -329,39 +329,109 @@ for the overlay application socket.
|
||||||
|
|
||||||
### 5.1 the composition
|
### 5.1 the composition
|
||||||
|
|
||||||
|
The maddr describes the composed network path and can be used as
|
||||||
|
either a source/listen or destination/dial handle. It does **not**
|
||||||
|
select the local instance of that network stack. A netns, VRF,
|
||||||
|
interface, user namespace, or equivalent platform resource is
|
||||||
|
orthogonal augmentation carried alongside/below the maddr.
|
||||||
|
|
||||||
|
Keep two bindspace representations with deliberately different
|
||||||
|
lifetimes:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
class BindspaceSpec(msgspec.Struct, frozen=True):
|
||||||
|
'''Serializable spawn/config declaration.'''
|
||||||
|
kind: str # `netns`, later `vrf`, ...
|
||||||
|
key: str|None # requested name/key, if any
|
||||||
|
|
||||||
|
|
||||||
|
class BindspaceIdentity(msgspec.Struct, frozen=True):
|
||||||
|
'''Stable identity of the realized platform resource.'''
|
||||||
|
kind: str
|
||||||
|
key: str|None
|
||||||
|
inode: int|None # Linux namespace identity
|
||||||
|
|
||||||
|
|
||||||
|
class BindspaceHandle:
|
||||||
|
'''Scoped, non-serializable capability for one live bindspace.'''
|
||||||
|
spec: BindspaceSpec
|
||||||
|
identity: BindspaceIdentity
|
||||||
|
namespace_fd: int|None
|
||||||
|
ownership: Literal['owned', 'borrowed']
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_bindspace(
|
async def open_bindspace(
|
||||||
addr: TunnelledAddress,
|
spec: BindspaceSpec,
|
||||||
) -> AsyncGenerator[Address, None]:
|
*,
|
||||||
|
role: Literal['listen', 'dial'],
|
||||||
|
) -> AsyncGenerator[BindspaceHandle, None]:
|
||||||
'''
|
'''
|
||||||
Enter the net-bindspace implied by `addr`'s tunnel stack,
|
Provision/borrow one bindspace and yield its live capability.
|
||||||
yielding the *overlay* `Address` ready to bind/connect.
|
|
||||||
|
|
||||||
Nests: one `@acm` per tunnel segment, outermost-first, so
|
|
||||||
a 2-deep stack is just two nested `async with`s and the
|
|
||||||
teardown order is guaranteed by `trio`.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
```
|
```
|
||||||
|
|
||||||
with per-tunnel-kind implementations:
|
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.
|
||||||
|
|
||||||
|
`open_bindspace()` is **not** an address factory and does not return a
|
||||||
|
`TunnelledAddress`. At the declaration layer, listener allocation can
|
||||||
|
use the handle to replace an overlay while preserving every tunnel:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with open_bindspace(
|
||||||
|
bindspace_spec,
|
||||||
|
role='listen',
|
||||||
|
) as bindspace:
|
||||||
|
listen_decl = declared_addr.get_random(
|
||||||
|
bindspace=bindspace,
|
||||||
|
)
|
||||||
|
transport_addr = strip_tunnels(listen_decl)
|
||||||
|
```
|
||||||
|
|
||||||
|
That sketch intentionally leaves the `.get_random()`/bindspace value
|
||||||
|
contract open. A concrete transport call returns a concrete overlay;
|
||||||
|
a declaration-level call may replace the overlay and return a new
|
||||||
|
`TunnelledAddress`. In either case wrappers remain until the final
|
||||||
|
transport bind/dial boundary, where `strip_tunnels()` is mandatory.
|
||||||
|
|
||||||
|
Per-platform provisioning still composes one resource context per
|
||||||
|
tunnel/bindspace layer:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@acm
|
@acm
|
||||||
async def open_netns(name: str) -> AsyncGenerator[None, None]: ...
|
async def open_netns(
|
||||||
|
spec: BindspaceSpec,
|
||||||
|
role: Literal['listen', 'dial'],
|
||||||
|
) -> AsyncGenerator[BindspaceHandle, None]: ...
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_wg_iface(spec: WGTunnelSpec) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
async def open_wg_iface(
|
||||||
|
spec: WGTunnelSpec,
|
||||||
|
bindspace: BindspaceHandle,
|
||||||
|
role: Literal['listen', 'dial'],
|
||||||
|
) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
and a driver that folds a list of specs into nested contexts
|
and a driver that folds a list of specs into nested contexts
|
||||||
(`contextlib.AsyncExitStack` for the N-deep case). The
|
(`contextlib.AsyncExitStack` for the N-deep case). The
|
||||||
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
||||||
it already returns `dict[name, list[Address]]` and the
|
it already returns
|
||||||
|
`dict[name, list[Address|TunnelledAddress]]` and the
|
||||||
`multiaddr_declare_eps.md` sketch anticipates the recursive
|
`multiaddr_declare_eps.md` sketch anticipates the recursive
|
||||||
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
||||||
entries. Extend it to carry the tunnel stack, not to *enter* it.
|
entries. Extend it to carry the tunnel stack, not to *enter* it.
|
||||||
|
|
||||||
|
The caller supplies `role`; do not infer it from maddr shape. The same
|
||||||
|
composed maddr can name a server source or client destination, and the
|
||||||
|
required local provisioning/ownership differs (§5.3).
|
||||||
|
|
||||||
### 5.2 `Address.namespace`, at last
|
### 5.2 `Address.namespace`, at last
|
||||||
|
|
||||||
- `TunnelledAddress.namespace` → `(kind, id)` e.g.
|
- `TunnelledAddress.namespace` → `(kind, id)` e.g.
|
||||||
|
|
@ -377,6 +447,13 @@ entries. Extend it to carry the tunnel stack, not to *enter* it.
|
||||||
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
|
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
|
||||||
the cheapest possible proof the layer is wired.
|
the cheapest possible proof the layer is wired.
|
||||||
|
|
||||||
|
Use `github/ns_aware@e4688cad` as prototype evidence, not code to
|
||||||
|
cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and
|
||||||
|
`ip netns identify` probe establish the useful `(key, inode)` identity
|
||||||
|
pair. Layer C should move that shape into `BindspaceIdentity`, avoid a
|
||||||
|
subprocess where netlink/procfs suffices, and hold the namespace FD in
|
||||||
|
`BindspaceHandle` to pin the identity.
|
||||||
|
|
||||||
### 5.3 the netns/process reality — read this before designing
|
### 5.3 the netns/process reality — read this before designing
|
||||||
|
|
||||||
**The headline consequence, stated up front**: netns is a
|
**The headline consequence, stated up front**: netns is a
|
||||||
|
|
@ -404,26 +481,52 @@ server bound in the old namespace.
|
||||||
- entering a netns is *process-global-ish and irreversible-ish*
|
- entering a netns is *process-global-ish and irreversible-ish*
|
||||||
in practice. Therefore: **netns membership belongs to the
|
in practice. Therefore: **netns membership belongs to the
|
||||||
actor process, decided before the runtime binds**, not to a
|
actor process, decided before the runtime binds**, not to a
|
||||||
mid-life `@acm`. Design:
|
mid-life actor API. Design:
|
||||||
- the root/parent decides the netns for a subactor and passes
|
- the root/parent decides the `BindspaceSpec`, provisions or
|
||||||
it in the spawn spec (there's already
|
borrows it, and passes the spec plus an inherited/transferred
|
||||||
|
namespace-FD capability through the spawn backend (there's already
|
||||||
`enable_transports`/`accept_addrs` plumbing at
|
`enable_transports`/`accept_addrs` plumbing at
|
||||||
`_runtime.py:1595-1615` — the netns rides alongside).
|
`_runtime.py:1595-1615` — the netns rides alongside).
|
||||||
- the child, in `_runtime.async_main()` **before**
|
- the child spawn/bootstrap trampoline calls `setns()` **before**
|
||||||
`IPCServer.listen_on()`, enters it.
|
`_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel
|
||||||
- the mid-life `@acm` form is then only for the *root* /
|
connection, or creation of any worker thread/socket.
|
||||||
single-actor case, and for iface creation (which is
|
- only after successful entry does the child drop namespace-entry
|
||||||
genuinely scoped).
|
privileges and initialize the actor runtime.
|
||||||
|
- a root/single-actor process follows the same ordering: enter during
|
||||||
|
root bootstrap, never after actor runtime startup.
|
||||||
|
- iface/route/WG provisioning is genuinely scoped and remains under
|
||||||
|
the parent/supervisor's `BindspaceHandle` context.
|
||||||
- document the constraint rather than hiding it; a
|
- document the constraint rather than hiding it; a
|
||||||
`RuntimeError` if `open_netns()` is entered after any
|
`RuntimeError` if namespace entry is attempted after bootstrap.
|
||||||
listener exists.
|
- capabilities: iface/netns creation/config needs `CAP_NET_ADMIN`;
|
||||||
- privileges: iface/netns creation needs `CAP_NET_ADMIN`.
|
entering an existing Linux namespace normally requires
|
||||||
Never `sudo` from inside the runtime. Two supported modes:
|
`CAP_SYS_ADMIN` in the owning user namespace. Never `sudo` from
|
||||||
|
inside the runtime. A privileged parent/helper should provision the
|
||||||
|
stack and open the namespace FD; the child receives only the scoped
|
||||||
|
capability and temporary authority needed to enter it, then drops
|
||||||
|
that authority before actor code runs. This separates create/config
|
||||||
|
authority from enter/use authority and fits user-namespace/capability
|
||||||
|
deployments without granting every actor broad ambient caps.
|
||||||
|
Two supported modes remain:
|
||||||
(i) pre-provisioned out-of-band (layers A/B — the default,
|
(i) pre-provisioned out-of-band (layers A/B — the default,
|
||||||
and what #482 documents), (ii) runtime-managed when the
|
and what #482 documents), (ii) runtime-managed when the supervising
|
||||||
process already holds the cap. Detect with a cheap
|
process/helper holds the required caps. Probe exact required caps and
|
||||||
`os.geteuid()==0 or CAP_NET_ADMIN in /proc/self/status`
|
*fail loudly with an actionable message* otherwise.
|
||||||
probe and *fail loudly with an actionable message* otherwise.
|
- role semantics are explicit:
|
||||||
|
- `listen`: may create/own the local bindspace, iface, routes, WG
|
||||||
|
peer/listener state, and random local overlay; lifetime normally
|
||||||
|
extends through all listeners and the actor process.
|
||||||
|
- `dial`: may borrow an actor-wide bindspace or ensure local routing
|
||||||
|
and tunnel state reaches the remote stack; it does not own the
|
||||||
|
remote maddr and may need no new local resource at all.
|
||||||
|
- source/destination use is an operation property, never permanently
|
||||||
|
encoded into the maddr or inferred from segment ordering.
|
||||||
|
- teardown follows capability ownership, not just address type:
|
||||||
|
- owned listener bindspaces tear down after endpoints/channels and
|
||||||
|
the actor process have exited;
|
||||||
|
- borrowed dial/actor-wide bindspaces only release their handle;
|
||||||
|
- nested resources exit inside-out, but shared resources remain until
|
||||||
|
their owning supervisor drops the final capability.
|
||||||
- teardown must be idempotent and tolerant: an iface/netns
|
- teardown must be idempotent and tolerant: an iface/netns
|
||||||
already gone must not strand the rest of the teardown — the
|
already gone must not strand the rest of the teardown — the
|
||||||
exact lesson `_uds.close_listener()`'s `FileNotFoundError`
|
exact lesson `_uds.close_listener()`'s `FileNotFoundError`
|
||||||
|
|
@ -442,6 +545,13 @@ server bound in the old namespace.
|
||||||
self-contained — no second host, no `sudo` in the test body.
|
self-contained — no second host, no `sudo` in the test body.
|
||||||
- the `to_thread`-netns-mismatch regression from §5.3, written
|
- the `to_thread`-netns-mismatch regression from §5.3, written
|
||||||
**first** (red), then the fix (green), per project convention.
|
**first** (red), then the fix (green), per project convention.
|
||||||
|
- bootstrap ordering: assert the child reports the expected namespace
|
||||||
|
inode before parent-channel connect and listener creation.
|
||||||
|
- FD capability: rename/unlink the namespace name after opening its FD
|
||||||
|
and prove child entry still selects the pinned inode.
|
||||||
|
- privilege drop: prove actor code lacks provisioning caps after entry.
|
||||||
|
- role/ownership: fake listen/dial resources and assert owned listener
|
||||||
|
teardown versus borrowed dial-handle release.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -475,8 +585,12 @@ consider doing it *first* for exactly that reason.
|
||||||
| risk | mitigation |
|
| risk | mitigation |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
|
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
|
||||||
|
| namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry |
|
||||||
|
| child starts sockets/threads before `setns()` | enter in the spawn bootstrap trampoline before `_runtime.async_main()`; assert inode ordering |
|
||||||
|
| ambient capabilities leak into actor app code | split provision/enter authority and drop caps before runtime initialization |
|
||||||
|
| dial path tears down a shared actor bindspace | encode ownership in `BindspaceHandle`; borrowed handles never remove resources |
|
||||||
| py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published |
|
| py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published |
|
||||||
| `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap at parse/bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` |
|
| `TunnelledAddress` leaks into transport reflection/type dispatch | keep wrappers through declaration/bindspace handling, call `strip_tunnels()` at channel/endpoint boundaries, and retain the boundary regressions |
|
||||||
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
|
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
|
||||||
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
|
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
|
||||||
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue