Add community node protocol spec, Agent Skills, and make the failing tests hermetic
DevPlace CI / test (push) Failing after 31m42s

Community nodes (Phase 1 of plan.md): the protocol specification in
services/nodes/spec.py as the single source of truth, GET /nodes/spec.json,
a generated protocol docs page, the Community nodes API docs group, and the
implementation plan plus the residential proxy research. The no-open-proxy rule
is enforced in the spec (open_proxy cannot be set).

Agent Skills: register the Skills API docs group, cascade skills on account
deletion, add the nested CLAUDE.md, README and routes docs, and cover the
parser, the fetcher (end to end against a mock origin), the data layer and the
routes with 73 unit and 16 api tests.

Tests made hermetic with the repo's existing patterns instead of ambient
environment: embeddings tests set OPENROUTER_API_KEY via monkeypatch, the
workspace tests install FakeBackend via runtime.set_backend, and the skills
install tests use run_async. DockerCliBackend now returns 127 (command not
found) when the CLI is missing instead of raising FileNotFoundError, so a host
without docker answers with a clean error rather than a 500. The Providers tab
empty state no longer names the Model routes tab.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 07:13:25 +02:00
co-authored by Claude Sonnet 5.5
parent cae139ead9
commit f55c7ee5f4
102 changed files with 7414 additions and 65 deletions
+55
View File
@@ -0,0 +1,55 @@
This file documents the community node network (`devplacepy/services/nodes/`, `devplacepy/routers/nodes/`): members opt in to donate a little of their connection so search can reach sites that block datacenter addresses. Claude Code auto-loads it whenever a file under `devplacepy/services/nodes/` is read or edited. The design record, with the measured and unmeasured claims separated, is `plan.md` at the repository root.
## What exists today
Only Phase 1 (the specification) is built. Everything else is planned and listed under "Gaps" next to the phase that closes it. Nothing here is enabled for any account.
| Piece | File | State |
|-------|------|-------|
| Protocol specification | `spec.py` | built, tested |
| `GET /nodes/spec.json` | `routers/nodes/spec.py` | built, tested, public |
| Generated protocol docs page | `templates/docs/nodes-protocol.html` | built, reads the `nodes_spec` Jinja global |
| API docs group | `docs_api/groups/nodes.py` | built, one endpoint |
## The specification is the contract
`spec.py` is the single source of truth. The server, the Go client, the browser extension, the docs page and the tests all read it. Nothing may restate a number, a frame field, an allowlisted host or a refusal code anywhere else: import it, or read it from `/nodes/spec.json`.
- `ProtocolSpec` holds `version`, `min_supported`, `LimitSpec`, `FrameSpec` tuple, `DestinationSpec` tuple and `RefusalSpec` tuple. Every one is a frozen dataclass. `SPEC.describe()` is the JSON form and the only thing the router returns.
- `open_proxy` is `field(default=False, init=False)`: it cannot be set through the constructor. **There is no open proxy mode and there will not be one.** Changing that is a protocol change that needs a new `PROTOCOL_VERSION`, not a flag.
- `ALLOWED_PORTS` is `(80, 443)`. A `DestinationSpec` may narrow it, never widen it (a unit test fails otherwise).
- `DestinationSpec.matches` is suffix-safe: a host matches when it equals the suffix or ends with `.` plus the suffix, so `evilreddit.com` and `reddit.com.evil.net` never match `reddit.com`.
- `LimitSpec` is the list of hard caps from the plan. Nothing is unbounded and nothing autotunes: a constant you can read beats magic you cannot debug. A number an admin may lower becomes a `ConfigField` later; the ceiling stays here.
- A frame type is unique per direction. An unknown type, a missing required field, an unexpected field or an oversize frame is a protocol error that closes the socket loudly, never an ignore.
The docs page `nodes-protocol.html` renders its tables from the `nodes_spec` Jinja global (`templating.py`, `NODES_SPEC.describe()`), so it cannot drift from the code. `tests/api/nodes/spec.py` asserts that every frame, destination, refusal code and limit name appears on the rendered page.
## Decisions taken (recorded so they are not re-litigated)
| Id | Decision |
|----|----------|
| D1 | devplacepy owns the node fabric; rsearch is a consumer, not the home |
| D2 | the native client is Go, one static binary per OS and architecture |
| D3 | the extension ships as a sideloadable zip first, store submission later |
| D4 | rewards are XP, badges and a leaderboard only: no coins, no money, nothing purchasable |
| D5 | donors must be adults (`age_band`) |
| D6 | a datacenter address is not a donor: residential and mobile only, admin override |
## Two transports, two trust levels
`tunnel` (native client) relays raw TCP so TLS runs end to end between DevPlace and the destination: the donor cannot read or forge content. `fetch` (browser extension, which has no raw sockets) lets the donor read and forge the response, so every `fetch` result is verified and labelled. A plain web page or a service worker cannot do either (CORS, no sockets) and is out of scope for good.
## Gaps (what the next phases close)
- Phase 2: tables, indexes, the `bandwidth_sharing` consent kind, `OWNED_TABLES` and `UNREPORTABLE_TABLES` entries, the usage ledger.
- Phase 3: registry, session, the consent choke point, the kill switch, device and ticket routes, the lock-owner WebSocket, the nginx upgrade location.
- Phase 4: broker, scheduler, dispatcher, destination policy, circuit breakers, result cache.
- Phase 5 onward: the reference client, adapters, the Go client, the extension, the profile UI, Devii tools, the CLI, the privacy and donor-terms pages, the legal gate.
- Not measured yet: Reddit and X success rates per address class, WebSocket idle timeouts through `pravda.education` and `devplace.net`, dispatcher tick cost against SQLite, socket capacity of one worker. The caps in `LimitSpec` are starting ceilings, not measurements.
## Rules for work in this directory
- Every user-facing or protocol-facing claim in the docs must describe what runs today. A page for an unbuilt feature states its status at the top, as `nodes-protocol.html` does.
- A new consent is enforced at one choke point, never at call sites (see `services/moderation/CLAUDE.md`).
- A new table here is either in `SOFT_DELETE_TABLES` or documented as derived and hard-garbage-collected; it is either in `REPORTABLE_TARGETS` or in `UNREPORTABLE_TABLES` with a reason.
- The machine may suspend a node (reversible, timed). It may never ban a user or delete a ledger row on its own: it raises a flag for a human.
+1
View File
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
+279
View File
@@ -0,0 +1,279 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
PROTOCOL_VERSION = 1
MIN_SUPPORTED_PROTOCOL = 1
CLIENT_TO_SERVER = "c2s"
SERVER_TO_CLIENT = "s2c"
MODE_TUNNEL = "tunnel"
MODE_FETCH = "fetch"
MODE_ANY = "any"
MODES = (MODE_TUNNEL, MODE_FETCH, MODE_ANY)
DEVICE_KINDS = ("native", "extension", "container")
DEVICE_STATUSES = ("active", "suspended", "revoked")
ASN_CLASSES = ("residential", "mobile", "datacenter", "unknown")
JOB_STATUSES = ("queued", "assigned", "done", "failed", "expired")
RESULT_VIA = ("tunnel", "fetch", "direct", "cache")
CLOSE_CAPACITY = 4029
CLOSE_RETRY_OWNER = 4013
CLOSE_FRAME_TOO_LARGE = 1009
CLOSE_POLICY = 1008
CLOSE_PROTOCOL = 4400
CLOSE_KICKED = 4403
ALLOWED_PORTS = (80, 443)
@dataclass(frozen=True)
class FieldSpec:
name: str
type: str
required: bool = True
@dataclass(frozen=True)
class FrameSpec:
type: str
direction: str
fields: tuple[FieldSpec, ...] = ()
max_bytes: int = 256 * 1024
def required(self) -> tuple[str, ...]:
return tuple(item.name for item in self.fields if item.required)
def allowed(self) -> frozenset[str]:
return frozenset(item.name for item in self.fields) | {"type"}
def describe(self) -> dict[str, Any]:
return {
"type": self.type,
"direction": self.direction,
"max_bytes": self.max_bytes,
"fields": [
{"name": item.name, "type": item.type, "required": item.required}
for item in self.fields
],
}
@dataclass(frozen=True)
class LimitSpec:
max_devices_per_user: int = 5
max_connected_nodes: int = 2000
max_frame_bytes: int = 256 * 1024
max_body_bytes: int = 2 * 1024 * 1024
max_concurrent_per_node: int = 2
min_seconds_per_node_domain: int = 6
job_timeout_seconds: int = 25
max_queue_depth: int = 500
default_wait_seconds: int = 15
max_wait_seconds: int = 60
default_daily_requests_user: int = 200
default_daily_requests_guest: int = 0
default_donor_mb_per_day: int = 500
max_donor_mb_per_day: int = 20 * 1024
ping_seconds: int = 20
missed_pings_before_evict: int = 2
reconnect_min_seconds: int = 1
reconnect_max_seconds: int = 60
ticket_ttl_seconds: int = 30
cache_ttl_seconds: int = 600
cache_max_bytes: int = 256 * 1024 * 1024
cache_entry_max_bytes: int = 4 * 1024 * 1024
usage_daily_retention_days: int = 400
event_retention_days: int = 30
event_max_rows: int = 50_000
request_retention_seconds: int = 600
xp_daily_cap: int = 50
dispatcher_tick_ms: int = 200
def describe(self) -> dict[str, int]:
return dict(self.__dict__)
@dataclass(frozen=True)
class DestinationSpec:
suffix: str
adapter: str
methods: tuple[str, ...] = ("GET",)
ports: tuple[int, ...] = ALLOWED_PORTS
modes: tuple[str, ...] = (MODE_TUNNEL, MODE_FETCH)
max_rps: float = 0.5
def matches(self, host: str) -> bool:
host = host.strip().lower().rstrip(".")
return host == self.suffix or host.endswith("." + self.suffix)
def describe(self) -> dict[str, Any]:
return {
"suffix": self.suffix,
"adapter": self.adapter,
"methods": list(self.methods),
"ports": list(self.ports),
"modes": list(self.modes),
"max_rps": self.max_rps,
}
@dataclass(frozen=True)
class RefusalSpec:
code: str
meaning: str
@dataclass(frozen=True)
class ProtocolSpec:
version: int
min_supported: int
limits: LimitSpec
frames: tuple[FrameSpec, ...]
destinations: tuple[DestinationSpec, ...]
refusals: tuple[RefusalSpec, ...]
open_proxy: bool = field(default=False, init=False)
def frame(self, frame_type: str, direction: str) -> FrameSpec | None:
for item in self.frames:
if item.type == frame_type and item.direction == direction:
return item
return None
def destination(self, host: str) -> DestinationSpec | None:
for item in self.destinations:
if item.matches(host):
return item
return None
def describe(self) -> dict[str, Any]:
return {
"version": self.version,
"min_supported": self.min_supported,
"open_proxy": self.open_proxy,
"allowed_ports": list(ALLOWED_PORTS),
"limits": self.limits.describe(),
"frames": [item.describe() for item in self.frames],
"destinations": [item.describe() for item in self.destinations],
"refusals": [
{"code": item.code, "meaning": item.meaning} for item in self.refusals
],
}
def _f(name: str, type_: str, required: bool = True) -> FieldSpec:
return FieldSpec(name, type_, required)
FRAMES: tuple[FrameSpec, ...] = (
FrameSpec(
"hello",
CLIENT_TO_SERVER,
(
_f("protocol", "int"),
_f("client_version", "str"),
_f("kind", "str"),
_f("platform", "str"),
_f("modes", "list"),
_f("limits", "object", False),
),
),
FrameSpec("pong", CLIENT_TO_SERVER),
FrameSpec(
"job_result",
CLIENT_TO_SERVER,
(
_f("job_id", "str"),
_f("status", "int"),
_f("headers", "object", False),
_f("body_b64", "str"),
_f("truncated", "bool", False),
_f("elapsed_ms", "int", False),
),
max_bytes=3 * 1024 * 1024,
),
FrameSpec("job_error", CLIENT_TO_SERVER, (_f("job_id", "str"), _f("code", "str"))),
FrameSpec(
"tunnel_data",
CLIENT_TO_SERVER,
(_f("conn_id", "str"), _f("b64", "str")),
),
FrameSpec("tunnel_close", CLIENT_TO_SERVER, (_f("conn_id", "str"),)),
FrameSpec(
"tunnel_opened",
CLIENT_TO_SERVER,
(_f("conn_id", "str"), _f("ok", "bool"), _f("code", "str", False)),
),
FrameSpec(
"usage",
CLIENT_TO_SERVER,
(_f("bytes_up", "int"), _f("bytes_down", "int")),
),
FrameSpec(
"welcome",
SERVER_TO_CLIENT,
(
_f("device_uid", "str"),
_f("limits", "object"),
_f("destinations", "list"),
_f("settings", "object"),
),
),
FrameSpec("ping", SERVER_TO_CLIENT),
FrameSpec(
"job",
SERVER_TO_CLIENT,
(
_f("job_id", "str"),
_f("method", "str"),
_f("url", "str"),
_f("headers", "object", False),
_f("timeout", "int"),
),
),
FrameSpec(
"tunnel_open",
SERVER_TO_CLIENT,
(_f("conn_id", "str"), _f("host", "str"), _f("port", "int")),
),
FrameSpec(
"tunnel_data",
SERVER_TO_CLIENT,
(_f("conn_id", "str"), _f("b64", "str")),
),
FrameSpec("tunnel_close", SERVER_TO_CLIENT, (_f("conn_id", "str"),)),
FrameSpec("settings_update", SERVER_TO_CLIENT, (_f("settings", "object"),)),
FrameSpec("kick", SERVER_TO_CLIENT, (_f("reason", "str"),)),
)
DESTINATIONS: tuple[DestinationSpec, ...] = (
DestinationSpec("reddit.com", "reddit"),
DestinationSpec("redd.it", "reddit"),
)
REFUSALS: tuple[RefusalSpec, ...] = (
RefusalSpec("host_not_allowed", "destination is not on the allowlist"),
RefusalSpec("port_not_allowed", "port is not 80 or 443"),
RefusalSpec("address_not_public", "resolved address is private, loopback, link-local, CGNAT or multicast"),
RefusalSpec("redirect_not_allowed", "redirect leaves the allowlist"),
RefusalSpec("method_not_allowed", "method is not permitted for the destination"),
RefusalSpec("bandwidth_exhausted", "donor daily bandwidth cap reached"),
RefusalSpec("concurrency_exceeded", "donor concurrency cap reached"),
RefusalSpec("outside_hours", "outside the donor's active hours"),
RefusalSpec("body_too_large", "response exceeds the body cap"),
RefusalSpec("timeout", "job exceeded its timeout"),
)
SPEC = ProtocolSpec(
version=PROTOCOL_VERSION,
min_supported=MIN_SUPPORTED_PROTOCOL,
limits=LimitSpec(),
frames=FRAMES,
destinations=DESTINATIONS,
refusals=REFUSALS,
)