marker: let callers pin the capture node via capture_node_id

A marker is single-sided — only the chosen capture node's uBridge installs the
mark filter — and dir=tx|rx is interpreted from that node's perspective. Until
now the observer was always auto-picked (_choose_marker_side), so dir=tx meant
"the auto-chosen endpoint is sending", which is unpredictable and makes the
direction filter hard to render meaningfully in the Web UI.

Add an optional create-only capture_node_id to MarkerCreate: when set, the
marker is pinned to that endpoint's uBridge (validated as a link endpoint and a
marker-capable type); when omitted, behavior is unchanged (auto-pick). The
chosen id is already echoed back as capture_node_id and in MARK signals, so the
UI can always render the observer regardless of who picked it.

capture_node_id is create-only (changing it would silently flip the meaning of
stored direction; recreate instead) and is not accepted on project-level
definitions — they are link-agnostic and have no endpoints, so inherited
markers keep auto-picking per link.

Plumbed through REST create_marker, the MCP link_marker tool, and base
Link.start_marker. update_marker does not forward it.
This commit is contained in:
YueGuobin committed 2026-08-01 22:24:47 +08:00
1 parent 6749b872fa
commit 71fa778d50
8 files changed
+112 -6

No files matched your search

+34
View File
@@ -98,6 +98,40 @@ async def test_start_marker_stores_entry(project):
assert "inherited_from" not in entry
@pytest.mark.asyncio
async def test_start_marker_pins_capture_node(project):
# Auto-pick would choose node1 (first endpoint); pin to node2 explicitly.
with _valid_bpf():
link = await _make_link(project)
chosen = link._nodes[1]["node"].id
auto = link._nodes[0]["node"].id
assert chosen != auto # sanity: the pin must actually mean something
await link.start_marker("icmp", "icmp", capture_node_id=chosen)
assert link.markers["icmp"]["capture_node_id"] == chosen
@pytest.mark.asyncio
async def test_start_marker_rejects_non_endpoint_capture_node(project):
with _valid_bpf():
link = await _make_link(project)
with pytest.raises(ControllerNotFoundError):
await link.start_marker("icmp", "icmp", capture_node_id="11111111-2222-3333-4444-555555555555")
@pytest.mark.asyncio
async def test_start_marker_capture_node_ignored_for_inherited(project):
# Definitions are link-agnostic: an inherited marker must auto-pick even
# if a capture_node_id leaks through, never trusting the caller's pin.
with _valid_bpf():
link = await _make_link(project)
leaked = link._nodes[1]["node"].id
await link.start_marker("m", "icmp", capture_node_id=leaked, inherited_from="arp")
assert link.markers["m"]["capture_node_id"] == link._nodes[0]["node"].id
@pytest.mark.asyncio
async def test_start_marker_rejects_duplicate(project):