From ba07e09d2a0ef439ec37587069d62f9d67ae53c6 Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 19 Aug 2026 22:31:56 -0400 Subject: [PATCH] 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`)) --- .../20260820T021516Z_dfad66a0_prompt_io.md | 41 +++++ ...20260820T021516Z_dfad66a0_prompt_io.raw.md | 34 ++++ ai/tpt-backends/00_shared_backend_contract.md | 7 +- ai/tpt-backends/03_wg_tunnel_bindspace.md | 172 +++++++++++++++--- 4 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.raw.md diff --git a/ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.md b/ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.md new file mode 100644 index 00000000..342756f7 --- /dev/null +++ b/ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.raw.md b/ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.raw.md new file mode 100644 index 00000000..da944e25 --- /dev/null +++ b/ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.raw.md @@ -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. diff --git a/ai/tpt-backends/00_shared_backend_contract.md b/ai/tpt-backends/00_shared_backend_contract.md index f46873a2..a7e0f22c 100644 --- a/ai/tpt-backends/00_shared_backend_contract.md +++ b/ai/tpt-backends/00_shared_backend_contract.md @@ -112,7 +112,12 @@ Hard constraints learned from the existing two: - **`.bindspace` semantics**: "the address' bindable space" — ip/host for `tcp`, the socket-file *directory* for `uds`. For 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 "the if-available OS-specific network namespace key" and is currently unimplemented by both backends — plan 03 is the diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 9d71acba..b6cb1bd5 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -329,39 +329,109 @@ for the overlay application socket. ### 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 +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 async def open_bindspace( - addr: TunnelledAddress, -) -> AsyncGenerator[Address, None]: + spec: BindspaceSpec, + *, + role: Literal['listen', 'dial'], +) -> AsyncGenerator[BindspaceHandle, None]: ''' - Enter the net-bindspace implied by `addr`'s tunnel stack, - 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`. + Provision/borrow one bindspace and yield its live capability. ''' ``` -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 @acm -async def open_netns(name: str) -> AsyncGenerator[None, None]: ... +async def open_netns( + spec: BindspaceSpec, + role: Literal['listen', 'dial'], +) -> AsyncGenerator[BindspaceHandle, None]: ... + @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 (`contextlib.AsyncExitStack` for the N-deep case). The `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 `dict[str, list[Address]]|dict[...]` return for tunnelled 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 - `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 the cheapest possible proof the layer is wired. +Use `github/ns_aware@e4688cad` as prototype evidence, not code to +cherry-pick unchanged. Its `/proc//ns/` 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 **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* in practice. Therefore: **netns membership belongs to the actor process, decided before the runtime binds**, not to a - mid-life `@acm`. Design: - - the root/parent decides the netns for a subactor and passes - it in the spawn spec (there's already + mid-life actor API. Design: + - the root/parent decides the `BindspaceSpec`, provisions or + 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 `_runtime.py:1595-1615` — the netns rides alongside). - - the child, in `_runtime.async_main()` **before** - `IPCServer.listen_on()`, enters it. - - the mid-life `@acm` form is then only for the *root* / - single-actor case, and for iface creation (which is - genuinely scoped). + - the child spawn/bootstrap trampoline calls `setns()` **before** + `_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel + connection, or creation of any worker thread/socket. + - only after successful entry does the child drop namespace-entry + 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 - `RuntimeError` if `open_netns()` is entered after any - listener exists. -- privileges: iface/netns creation needs `CAP_NET_ADMIN`. - Never `sudo` from inside the runtime. Two supported modes: + `RuntimeError` if namespace entry is attempted after bootstrap. +- capabilities: iface/netns creation/config needs `CAP_NET_ADMIN`; + entering an existing Linux namespace normally requires + `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, - and what #482 documents), (ii) runtime-managed when the - process already holds the cap. Detect with a cheap - `os.geteuid()==0 or CAP_NET_ADMIN in /proc/self/status` - probe and *fail loudly with an actionable message* otherwise. + and what #482 documents), (ii) runtime-managed when the supervising + process/helper holds the required caps. Probe exact required caps 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 already gone must not strand the rest of the teardown — the 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. - the `to_thread`-netns-mismatch regression from §5.3, written **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 | | --- | --- | | `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 | -| `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 | | 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()` |