Add community node protocol spec, Agent Skills, and make the failing tests hermetic
DevPlace CI / test (push) Failing after 31m42s
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:
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user