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()` |