Skip to content

knoten serve: a remote graph friends can be invited to - #33

Merged
BY571 merged 26 commits into
share/server-gatefrom
remote/transport
Sep 5, 2026
Merged

knoten serve: a remote graph friends can be invited to#33
BY571 merged 26 commits into
share/server-gatefrom
remote/transport

Conversation

@BY571

@BY571 BY571 commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Phase 1 of the remote-graphs design: transport. A graph can be created on a
knoten serve server, a friend invited with read or write rights, and both can
push and pull through the rule gate over HTTP.

# you, once, in your graph
knoten remote create trading --on https://graphs.example
knoten invite maria --role write        # prints a one-time code

# maria, anywhere
knoten join https://graphs.example/trading --invite 7f3a9c...
knoten frontier                         # her clone; the loop is unchanged from here
knoten push                             # over HTTPS, through the gate

What it is

The server is the standard library in front of git http-backend, which ships
with git. Its one job per request is to decide whether this token may do this;
packfiles, refs, negotiation and the pre-receive gate are git's. git's own
protocol already separates reading from writing by endpoint, so a read token
is one that never gets past the door for git-receive-pack.

State is three hashed JSON files per graph (tokens, invites) plus one owner
secret, under the same flock the graph uses locally. Nothing in them is needed
to interpret a graph: losing the server loses availability, not meaning.

What was found in review, and fixed

Every task was reviewed by a fresh agent; a final whole-branch review ran on
the most capable model. Things that were wrong in the plan and are right now:

  • create() rolls back a half-made repo; a graph that exists() but has no gate
    would have accepted pushes forever.
  • A crashed git http-backend is a 500, not a silent 200.
  • Non-object JSON, non-numeric days, a malformed or negative Content-Length,
    and chunked bodies are refusals, not tracebacks or a parked thread.
  • --bind is parsed and the socket bound before the owner secret is written,
    so a typo cannot orphan the secret unseen.
  • The credentials file is fchmoded 0600 on every write, not just on creation.
  • Secrets that travel on a command line (--owner-secret, --invite) are hex,
    because token_urlsafe can start with - and argparse then reads it as a flag.
  • _explain matches git's phrasing, not bare digits: the ephemeral port was
    flipping 401/403 verdicts, which was the intermittent test.
  • /join answers an unknown graph exactly as it answers a wrong code.
  • join says the invite is spent and credentials are saved when a clone fails
    after redemption.

Testing

Real git against a real knoten serve on a random port throughout; the only
fakes are a crashed backend (real git cannot be made to crash headerless on
demand), a stubbed getpass prompt, and serve_forever in three CLI tests.
398 passed, no warnings.

Not in this PR, by design

Signed commits, contributors in graph.yaml, and the verification quorum are
phases 2 and 3 of the same spec. Parked with rulings for the next phase: a
timing difference on /join between unknown-graph and wrong-code refusals
(identical bodies, localhost-bound behind a proxy), and _admin answering 403
where a missing token could be 401.

Base is share/server-gate (#32) so this diff is phase 1 alone; retarget to
master once #32 merges.

🤖 Generated with Claude Code

https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb

BY571 and others added 26 commits September 5, 2026 12:13
When git init, config, or install_server fail partway through, the repo
directory exists on disk but lacks the pre-receive gate. Subsequent creates
raise 'already exists' forever, blocking retry. Wrap the whole sequence in
try/except, rollback with rmtree on any exception, and re-raise as GraphError
so the invariant holds: if exists() is true, the gate is installed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
graph_lock tries to open a file inside the graph directory, which raises a raw
FileNotFoundError if the path does not exist. Like authenticate/invite/redeem,
revoke must call self.repo(name) first to convert this to a domain GraphError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
git http-backend can exit non-zero with no CGI header block at all — a genuine
internal error, not a gate refusal (those travel the sideband with exit 0). The
relay used to fall back to status 200 in that case, handing the client an empty
200 OK while the real failure sat only in the server's own stderr.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…de joins

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
_json_body() now validates that parsed JSON is a dict, not a list or other
type, preventing AttributeError on .get(). _invite() wraps int(days) in
try-except to catch non-numeric values. Both now raise GraphError for 400
instead of uncaught exceptions. Added comments to _create() and _admin()
explaining security and response sequencing rationale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
validate the --bind port before writing the owner secret, so a typo like
--bind localhost:abc fails early without orphaning the secret on disk.
also call server_close() in a finally block to close the socket when
serve_forever() returns, fixing ResourceWarning on exit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
os.open's mode argument only applies when creating a new file; existing files
keep their original permissions. A credentials file pre-existing with looser bits
would leak tokens to other local users on every write. Call os.fchmod after open
to enforce secure permissions regardless of file age.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
token_urlsafe's alphabet includes '-', and a value beginning with '-' reads
to argparse as a flag, not an option's value — knoten remote create failed
one run in five with a perfectly valid --owner-secret. owner_secret() and
invite() now use token_hex; mint()'s tokens travel only as a git HTTP
password, never argv, so they keep token_urlsafe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
getpass.getpass raised an uncaught EOFError with no terminal to prompt on
(cron, CI, a pipe), producing a traceback instead of a one-line refusal.
And _explain matched "401"/"403" against relayed remote: lines too, so a
rule violation naming a node id like hyp-401-alive read as a credentials
problem instead of the actual gate failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Pointing sys.stdin at an empty StringIO made getpass.fallback_getpass print
a GetPassWarning about not controlling terminal echo on every run. Stubbing
knoten.remote.getpass.getpass to raise EOFError directly gets the same
no-terminal behaviour without the warning, keeping test output pristine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The server consumes the code before git clone runs, so a clone failure left the
user only git's error and a stored credential nobody explained — they retried
the same code and got refused for what looked like an unrelated reason.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A bare "401"/"403" substring search also matched git's own `fatal: unable
to access '...'` line, so a port or graph name containing those three
digits flipped the verdict -- the hub fixture binds port 0, and plenty
of ephemeral ports contain "401". Match `returned error: 401/403` and
`HTTP 401/403` instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
/join takes no credentials, so it must answer a wrong code and an
unknown graph identically -- refusing to touch registry.redeem for a
graph that does not exist keeps /join from being a name oracle, matching
what /git already does for git-receive-pack and git-upload-pack.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
"Content-Length: abc" raised ValueError unguarded, escaping _route's
except GraphError and killing the thread with no response to the
client. "Content-Length: -1" reached rfile.read(-1), which reads until
the socket closes -- an unauthenticated thread-exhaustion primitive on
a connection the client never closes. Both are now a 400 GraphError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
_body only reads Content-Length bytes; a Transfer-Encoding: chunked
push (git goes chunked above http.postBuffer, default 1 MiB) handed
http-backend an empty stdin, which died and left the client with an
inscrutable bare 500. A plain `git clone` of a hosted graph, which the
README treats as normal, hit this on any push over 1 MiB. Refuse with
a 411 that names the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
- serve.py: skip a relayed content-length header before appending
  knoten's own, so a dumb-protocol response does not carry two.
- registry.py: entry.get("hash", "") instead of entry["hash"], so a
  hand-edited or truncated tokens.json fails closed, not with a
  traceback.
- README.md: the example invite code is pure lowercase hex
  (token_hex), not dash-separated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
@BY571
BY571 merged commit c783477 into share/server-gate Sep 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant