diff --git a/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.md b/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.md new file mode 100644 index 00000000..ba94cf2c --- /dev/null +++ b/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.md @@ -0,0 +1,41 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-27T23:25:00Z +git_ref: d067505a +scope: code +substantive: true +raw_file: 20260827T232500Z_d067505a_prompt_io.raw.md +--- + +## Prompt + +After committing the reviewed bindspace-reference changes, the human +asked to continue with the next Layer C increment. + +## Response summary + +Added the private Linux network-namespace entry primitive required by +future child bootstrap wiring. It verifies the inherited capability +before `setns()`, constrains entry to network namespaces, and verifies +the resulting process namespace before runtime initialization proceeds. + +## Files changed + +- `tractor/spawn/_netns.py` - synchronous verified netns entry. +- `tests/test_netns_spawn.py` - stale-FD, syscall and post-entry guards. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - primitive ownership and + remaining integration boundary. + +## Human edits + +The human requested the next incremental implementation after accepting +the prior split commits. Their established preference for end-to-end +tests informed use of real FDs where possible; only privileged `setns()` +and procfs observation are replaced to keep the suite unprivileged. In +staged review, the human requested the full project license header, +explicit comments on both intentional inode mismatches, and independent +call traces proving both fake syscall and procfs hooks execute. The +agent applied those human-directed revisions; no direct manual source +edits were observed. diff --git a/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.raw.md b/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.raw.md new file mode 100644 index 00000000..4b03038e --- /dev/null +++ b/ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.raw.md @@ -0,0 +1,25 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-27T23:25:00Z +git_ref: d067505a +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - verify pre-runtime netns entry + +After committing realized bindspace references and their deferred codec +plan, the human requested the next Layer C increment. + +> `git diff HEAD~1..HEAD -- tractor/spawn/_netns.py tests/test_netns_spawn.py ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Added a private synchronous `enter_netns()` bootstrap primitive. It +validates the inherited FD and expected inode, constrains `setns()` to +`CLONE_NEWNET`, verifies `/proc/self/ns/net` afterward, and leaves FD +closure to the future spawn-bootstrap caller. + +Tests use real stand-in FDs while replacing only the privileged syscall +and post-entry procfs observation. They prove stale FDs fail before +entry, the syscall receives the exact namespace type, and bootstrap +rejects an unexpected post-entry namespace. Ruff and all three focused +tests passed. diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 396cb722..be0b9fba 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -621,6 +621,12 @@ server bound in the old namespace. - the child spawn/bootstrap trampoline calls `setns()` **before** `_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel connection, or creation of any worker thread/socket. + - `spawn._netns.enter_netns()` is the first private bootstrap + primitive: it checks the inherited FD against the expected inode, + calls `setns(fd, CLONE_NEWNET)`, and verifies + `/proc/self/ns/net` before returning. It deliberately does not own + or close the FD; spawn propagation and status reporting remain the + caller's next integration boundary. - 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 diff --git a/tests/test_netns_spawn.py b/tests/test_netns_spawn.py new file mode 100644 index 00000000..839236c6 --- /dev/null +++ b/tests/test_netns_spawn.py @@ -0,0 +1,142 @@ +''' +Pre-runtime Linux network-namespace entry validation. + +''' +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import BinaryIO + +import pytest + +from tractor.spawn import _netns + + +def test_enter_netns_rejects_mismatched_inherited_fd( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + A stale inherited FD must not enter a replacement namespace. + + Open a real stand-in FD, declare a different expected inode and + replace `os.setns()` with a failure sentinel. The inode check must + reject the capability before any irreversible namespace entry. + + ''' + token_path: Path = tmp_path / 'netns' + token_path.touch() + + def fail_setns(namespace_fd: int, nstype: int) -> None: + raise AssertionError('`setns()` must not be called') + + monkeypatch.setattr(_netns.os, 'setns', fail_setns) + namespace_file: BinaryIO + with token_path.open('rb') as namespace_file: + inode: int = token_path.stat().st_ino + with pytest.raises( + ValueError, + match=f'{inode}.*{inode + 1}', + ): + _netns.enter_netns( + namespace_file.fileno(), + # Deliberately differ from `token_path`'s inode. + inode + 1, + ) + + +def test_enter_netns_verifies_post_entry_inode( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Successful `setns()` is insufficient without post-entry proof. + + Use a real inherited FD and fake only the privileged syscall and + `/proc/self/ns/net` observation. The recorded calls prove both + hooks execute and `CLONE_NEWNET` constrains the namespace type; + the returned inode proves bootstrap observed the expected netns. + + ''' + token_path: Path = tmp_path / 'netns' + token_path.touch() + setns_calls: list[tuple[int, int]] = [] + stat_calls: list[Path] = [] + + def fake_setns(namespace_fd: int, nstype: int) -> None: + setns_calls.append((namespace_fd, nstype)) + + def fake_stat(path: Path) -> SimpleNamespace: + stat_calls.append(path) + return SimpleNamespace(st_ino=inode) + + namespace_file: BinaryIO + with token_path.open('rb') as namespace_file: + namespace_fd: int = namespace_file.fileno() + inode: int = token_path.stat().st_ino + monkeypatch.setattr(_netns.os, 'setns', fake_setns) + monkeypatch.setattr( + type(_netns._SELF_NETNS), + 'stat', + fake_stat, + ) + + entered_inode: int = _netns.enter_netns( + namespace_fd, + inode, + ) + + assert setns_calls == [ + (namespace_fd, _netns.os.CLONE_NEWNET), + ] + assert stat_calls == [_netns._SELF_NETNS] + assert entered_inode == inode + + +def test_enter_netns_rejects_wrong_post_entry_namespace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ''' + Bootstrap must stop when the process lands in an unexpected netns. + + Let the inherited FD check and fake syscall succeed, then report a + different `/proc/self/ns/net` inode. The post-entry guard must raise + instead of allowing actor runtime sockets to start in the wrong + namespace. + + ''' + token_path: Path = tmp_path / 'netns' + token_path.touch() + + def fake_setns(namespace_fd: int, nstype: int) -> None: + return None + + monkeypatch.setattr( + _netns.os, + 'setns', + fake_setns, + ) + + namespace_file: BinaryIO + with token_path.open('rb') as namespace_file: + inode: int = token_path.stat().st_ino + + def fake_stat(path: Path) -> SimpleNamespace: + return SimpleNamespace(st_ino=inode + 1) + + monkeypatch.setattr( + type(_netns._SELF_NETNS), + 'stat', + fake_stat, + ) + with pytest.raises( + RuntimeError, + match=f'{inode + 1}.*{inode}', + ): + _netns.enter_netns( + namespace_file.fileno(), + # Deliberately differ from `fake_stat()`'s inode + 1. + inode, + ) diff --git a/tractor/spawn/_netns.py b/tractor/spawn/_netns.py new file mode 100644 index 00000000..5251fb49 --- /dev/null +++ b/tractor/spawn/_netns.py @@ -0,0 +1,104 @@ +# 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 . + +''' +Linux network-namespace actor-bootstrap primitives. + +''' +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import os +import sys + + +_SELF_NETNS: Path = Path('/proc/self/ns/net') + + +def enter_netns( + namespace_fd: int, + expected_inode: int, +) -> int: + ''' + Enter and verify one inherited Linux network namespace. + + The future spawn-bootstrap caller owns and closes `namespace_fd`. + + ''' + if sys.platform != 'linux': + raise RuntimeError( + 'Network namespace entry is Linux-only!' + ) + if ( + type(namespace_fd) is not int + or + namespace_fd < 0 + ): + raise ValueError( + '`namespace_fd` must be a non-negative `int`!' + ) + if ( + type(expected_inode) is not int + or + expected_inode <= 0 + ): + raise ValueError( + '`expected_inode` must be a positive `int`!' + ) + + setns: Callable[[int, int], None]|None = getattr( + os, + 'setns', + None, + ) + clone_newnet: int|None = getattr( + os, + 'CLONE_NEWNET', + None, + ) + if ( + setns is None + or + clone_newnet is None + ): + raise RuntimeError( + 'Python has no Linux network namespace entry support!' + ) + + inherited_inode: int = os.fstat(namespace_fd).st_ino + if inherited_inode != expected_inode: + raise ValueError( + f'Inherited namespace FD inode {inherited_inode} does not ' + f'match expected inode {expected_inode}!' + ) + + try: + setns(namespace_fd, clone_newnet) + except OSError as exc: + raise RuntimeError( + f'Could not enter network namespace inode ' + f'{expected_inode}!' + ) from exc + + entered_inode: int = _SELF_NETNS.stat().st_ino + if entered_inode != expected_inode: + raise RuntimeError( + f'Entered network namespace inode {entered_inode} does not ' + f'match expected inode {expected_inode}!' + ) + + return entered_inode