knoten serve: a remote graph friends can be invited to - #33
Merged
Conversation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
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
… not 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
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
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
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
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
…mmands 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
…none Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1 of the remote-graphs design: transport. A graph can be created on a
knoten serveserver, a friend invited with read or write rights, and both canpush and pull through the rule gate over HTTP.
What it is
The server is the standard library in front of
git http-backend, which shipswith 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
readtokenis 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 thatexists()but has no gatewould have accepted pushes forever.
git http-backendis a 500, not a silent 200.days, a malformed or negativeContent-Length,and chunked bodies are refusals, not tracebacks or a parked thread.
--bindis parsed and the socket bound before the owner secret is written,so a typo cannot orphan the secret unseen.
fchmoded 0600 on every write, not just on creation.--owner-secret,--invite) are hex,because
token_urlsafecan start with-and argparse then reads it as a flag._explainmatches git's phrasing, not bare digits: the ephemeral port wasflipping 401/403 verdicts, which was the intermittent test.
/joinanswers an unknown graph exactly as it answers a wrong code.joinsays the invite is spent and credentials are saved when a clone failsafter redemption.
Testing
Real git against a real
knoten serveon a random port throughout; the onlyfakes are a crashed backend (real git cannot be made to crash headerless on
demand), a stubbed
getpassprompt, andserve_foreverin three CLI tests.398 passed, no warnings.
Not in this PR, by design
Signed commits, contributors in
graph.yaml, and the verification quorum arephases 2 and 3 of the same spec. Parked with rulings for the next phase: a
timing difference on
/joinbetween unknown-graph and wrong-code refusals(identical bodies, localhost-bound behind a proxy), and
_adminanswering 403where a missing token could be 401.
Base is
share/server-gate(#32) so this diff is phase 1 alone; retarget tomasteronce #32 merges.🤖 Generated with Claude Code
https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb