Skip to content

shared graphs: the gate on the server, and knoten serve for remotes friends can be invited to - #32

Open
BY571 wants to merge 49 commits into
masterfrom
share/server-gate
Open

shared graphs: the gate on the server, and knoten serve for remotes friends can be invited to#32
BY571 wants to merge 49 commits into
masterfrom
share/server-gate

Conversation

@BY571

@BY571 BY571 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

A graph is a folder in git, so sharing one is already git push. What was missing is the
gate. knoten hook protects the person who ran it, in the clone they ran it in, and
git commit --no-verify walks past it. On a shared graph the normal contributor is one
who never ran it, which left the rules resting on everyone's individual discipline. That
is precisely the failure the rule engine exists to replace.

What this adds

knoten hook --server [REPO] installs a pre-receive hook on the repo everyone pushes
to. It unpacks the tree being pushed, finds every graph in it, runs knoten validate
on each, and refuses the push if any fails.

# on any box you and your collaborators can reach
git init --bare lab-graph.git
knoten hook --server lab-graph.git

# everyone else, human or agent
git clone you@box:lab-graph.git

No CI, no runner, no minutes, and nobody can skip it from a laptop. It is also strictly
better than a CI check for this job: it refuses the push instead of reporting afterwards
that master is broken.

Three things it has to get right

Each has a test that fails without it.

It finds the graph rather than recording a path. A bare repo has no working tree, so
there is no graph.yaml to read at install time. A path recorded then would rot the
moment someone moved the folder, and rot silently: the hook would find no graph and
accept everything while reporting green.

It fails closed. knoten missing from the server's PATH refuses the push rather than
waving it through. The test actually strips PATH rather than grepping the script for a
guard.

graph.yaml is not a name knoten owns. Another tool's config of the same name fails
validation, and treating it as a graph would make the whole repo unpushable forever,
citing a file nobody thinks of as a graph. A graph is one with a nodes/ directory or
a knoten-specific key. Both tests are needed: git does not track an empty nodes/, so a
graph whose nodes were all deleted slips through the first, and a graph.yaml too
malformed to name its keys slips through the second.

One shell detail worth flagging in review: git archive and tar are two statements
rather than a pipe, because POSIX sh reports only the last command's status. A pipeline
would hide a failed archive behind a happy tar and accept the push unchecked.

Testing

tests/test_server_hook.py, 23 tests, all doing real pushes against real bare repos
rather than asserting on the script's text. Full suite 314 passed.

Covered: rejection and acceptance; a graph in a subdirectory; two graphs in one repo;
a repo with no graph; branch deletion (all-zeros sha); knoten absent from PATH; an
unrelated graph.yaml; malformed graph.yaml; an unparseable node; deleting the graph;
every ref in one git push --all; a non-master branch; a force push; a tag; temp
directory cleanup under a controlled TMPDIR; and a repo path containing a space, which
is the classic way this breaks and it breaks open.

Verified end to end outside the suite: a contributor who never ran knoten hook and used
git commit --no-verify was still refused, and the server kept only the clean commit.
knoten viz was rendered from a fresh clone of the shared repo to confirm the read-only
path needs nothing installed.

Docs

README gains a ## A shared graph section covering the setup, why nodes go straight to
master while graph.yaml is the file worth protecting, why concurrent edits do not
collide (edges are declared once on the subject, back-links are generated at load), and a
pointer to Forgejo for anyone wanting per-user permissions or required approvals, since
that is the forge's job and not knoten's. SKILL.md gains a pull-first line, because a
frontier computed from a week-old clone recommends work a collaborator already settled.

Not in scope

Cross-graph citation, a graph-level diff, and any notion of accounts or approval quorums.
Approval quorums in particular belong in a forge: pre-receive is binary and has no
pending state, so building "this needs two approvals" on bare git means reinventing pull
requests.

🤖 Generated with Claude Code

https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb

BY571 and others added 28 commits September 4, 2026 22:08
… clone

`knoten hook` gates one person, in one clone, and `git commit --no-verify` walks
past it. On a shared graph the normal contributor is one who never ran it, so the
rules held only as far as everyone's individual discipline. That is the failure
mode the rule engine exists to replace.

`knoten hook --server` installs a pre-receive hook on the repo everyone pushes to.
It unpacks the pushed tree, finds every graph in it, runs `knoten validate` on
each and refuses the push if any fails. No CI, no runner, no minutes, and it
cannot be skipped from a laptop.

Three things it has to get right, each with a test that fails without it:

- It FINDS the graph rather than recording a path. A bare repo has no working
  tree to read one from, and a recorded path rots silently the moment someone
  moves the folder: the hook then finds no graph and accepts everything.
- It fails closed. knoten missing from the server's PATH refuses the push. A gate
  that waves work through when it cannot check it is not a gate.
- `graph.yaml` is not a name knoten owns. Another tool's file of the same name
  fails validation, and treating it as a graph would make the whole repo
  unpushable forever. A graph is one with a nodes/ directory or a knoten-specific
  key; two tests, because git does not track an empty nodes/ and a graph.yaml too
  malformed to name its keys still has to be caught.

`git archive` and `tar` are two statements rather than a pipe: POSIX sh reports
only the last command's status, so a pipeline would hide a failed archive behind
a happy tar and accept the push unchecked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
… closed

Three agents reviewed the branch. Applying what they found.

The discriminator was `nodes/ exists OR graph.yaml declares a knoten key`. But
`rules:` and `tags:` are ordinary top-level YAML keys, used by Ansible, CI configs
and doc generators, so the second clause matched foreign files and handed them to
`validate`, which rejects unknown keys. One vendored graph.yaml with a `tags:` key
would have made the whole repo unpushable forever, citing a file nobody thinks of
as a graph. That is the exact failure the first clause exists to prevent.

The clause bought one thing: a graph whose nodes were all deleted, a window one
push wide. It only ever fired because the fixture's `nodes/` was empty and git
does not track an empty directory, so the fixture was testing a shape that cannot
reach a server. The fixture now carries a node, which is what a graph in git
always has, and the clause is gone.

Also from the review:

- `install` and `install_server` shared ten lines including a verbatim error
  message. One `_write_hook` helper now holds the clobber rule, which is the half
  a reader trusts rather than checks, so it cannot drift between the two gates.
- `_git(repo, "rev-parse", "--git-dir")` was dead: `hooks_dir` calls `_git` on the
  next line and raises the same error.
- `ZERO=` assumed SHA-1. A SHA-256 repo sends 64 zeros on a deletion, the guard
  would miss, and a routine branch delete would be refused. Matched by shape now.
- `knoten validate` inherited the hook's stdin, which is the ref list. Any future
  read inside validate would have eaten refs and left them unchecked. `</dev/null`.
- Dropped a dead `[ -n "$cfg" ]` guard, hoisted a loop-invariant, and merged the
  duplicated archive/tar error message while keeping them two statements.

Tests: 23 -> 22. Deleted `test_deleting_the_graph_entirely_is_allowed` (the hook is
stateless per push, so "graph removed" and "never had one" reach the same branch),
folded the two CLI install tests into one parametrized pair, and gave the six tests
that lacked one a docstring naming the failure they guard.

Two review suggestions declined: `test_it_refuses_outside_a_git_repo` stays, since
it pins a user-facing error on a distinct entry point for one line; and the
branch/force-push/tag tests stay separate, because their setups genuinely differ
and parametrizing them would need a callable per case and read worse.

README section cut from 52 lines to 32.

Co-Authored-By: Claude Opus 5 (1M context) <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
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
A symlink in a pushed tree resolves against the server, not the pusher. A write
user pushed `g/nodes -> /some/server/dir` and the gate validated that directory,
echoing its file names back on the `remote:` lines. The unpacked tree now loses
every symlink, and a graph must have a real nodes/ next to its graph.yaml.

The find-into-a-file-then-read loop also split any path containing a newline
across two lines and validated neither, so `git archive` names that `git mktree`
accepts walked past the gate. -exec passes the paths as arguments instead;
`read -d ""` would be the other answer but it is bash, not the /bin/sh this hook
runs under.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
BY571 and others added 20 commits September 5, 2026 15:51
A write collaborator's stray --force wiped a shared graph with no reflog to
recover from and no line in any log saying it had happened, so a created repo now
sets denyNonFastForwards, denyDeletes, logAllRefUpdates and fsckObjects.

The rest are refusals that used to be crashes or silent grants: a graph recreated
over a leftover directory inherited the deleted one's tokens.json; an empty owner
file made "" a valid owner secret, and check_owner would create the file it was
meant to check; --expires 999999999999 reached timedelta and killed the thread;
a revoked admin's outstanding invites still redeemed as admin; a name with no
length bound surfaced NAME_MAX as an opaque OSError; the data directory was
world-readable on a server with more than one account.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Five ways a request killed a serving thread instead of getting an answer: a
non-UTF-8 body on unauthenticated /join, a Content-Length whose body never
arrived, a chunked body on the API routes, an absurd invite lifetime, and
anything else at all, which now lands on a final except and a 500.

`service=git-receive%2Dpack` walked a read token past the write gate, because
the gate searched the raw query for a word git only sees after decoding it. The
query is parsed and the service compared whole.

http-backend and the pre-receive hook no longer inherit os.environ wholesale: a
GIT_DIR or GIT_CONFIG_* left in the operator's shell reached a hook running
against a tree an attacker chose. And the five events an owner would want to
audit now leave a line on stderr; reads stay silent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The credential key was host plus path, so a token held for an https server also
answered git's request for the same host over plain http and crossed the wire in
the clear. The key now carries the scheme, and the owner secret moves to
owner://<host>, which no git request can ask for.

A join reply is written straight into a file that is one line per remote, so a
hostile server sending a name with a newline in it appended a credential for a
host the user never named. Name, role and token are all checked before anything
reaches disk.

Also: the owner secret can come from KNOTEN_OWNER_SECRET instead of argv, where
every other process on the machine can read it; a push that fails after the
graph was created now says the graph exists and how to recover; and a failing
fchmod closes its fd.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
git() and commit_node() were copied into three files, and the copy in
test_server_hook.py was the one that forgot GIT_ISOLATION: those tests ran
against the developer's own global git config. Both now live in conftest.

New: a token revoked between info/refs and the POST that carries the pack, since
each request is authenticated on its own; an annotated tag, which reaches the
hook as a tag object and not a commit; a 64 KB body, which is more than one
read; and a hand-built Expect: 100-continue request, which git can never send
because it strips the header from its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
An invite is a bearer secret sitting on the server until someone redeems it. An
admin who cannot list them cannot tell a forgotten invite from a revoked one,
and had no way to see who had issued which. The list never carries the hashes.

Also a section divider over the remote and server half of cli.py, which had run
together with the read commands above it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Both came out of the reviews as questions, and both have an answer that is a
decision rather than an oversight: admins can revoke each other including the
creator, and a token already past authentication finishes its push. Section 12
was missing entirely, so the table is new; it carries only these two rows rather
than inventing decisions nobody has made.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The sharing sentence had been spliced into the intro mid-paragraph; it is its
own paragraph now. Install line works for someone who has not cloned. The loop
block reads as one column again and shows pull and push. The shared-graph
section gains invites, revoke, where state lives (hashed tokens and open
invites on the server, everything that means anything in the graph), and the
owner secret's one job. "Or a hosted knoten" now says "later", since none
exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The git that installs the gate and the git that enforces it were answering
"where do hooks live" under different configuration. HOME survives the CGI
whitelist, so a core.hooksPath in the daemon account's ~/.gitconfig sent
receive-pack looking somewhere the hook had never been written: a push that
breaks the graph landed with rc 0 and the server reported success.

SERVER_GIT_ENV pins GIT_CONFIG_GLOBAL and GIT_CONFIG_NOSYSTEM for the CGI
environment, for both subprocesses in Registry.create, and for the hooks_dir
lookup install_server makes. The client-side `knoten hook` keeps honouring
core.hooksPath, which is the whole point of it in a clone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The Basic username is chosen by whoever is connecting and is logged before it is
authenticated, on the refused-push path. A newline in it wrote a second line
into the access log, so an attacker could invent pushes that never happened, by
anyone they liked. The logged user and action keep printable non-space
characters only, 64 at most.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A zero-byte `owner` is what a crash between the create and the write leaves
behind, and it counted as already made: serve printed nothing, check_owner had
nothing to compare against, and every POST /graphs was a 401 forever with
nothing on disk to explain it. ensure_owner_secret regenerates on an empty file
and tells serve whether this run is the one that made the secret, rather than
serve guessing from whether the file exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The length cap raised on the read path too, so a 65-character name came back as
a 400 where every unknown graph is a 401, and gave /join a body different from
the one pinned to be identical to a wrong code. That is the name oracle the
identical bodies exist to close. exists() now answers False for an over-length
name and never raises; create and mint keep the refusal, where the caller is
naming something they own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Adding the scheme to the key silently invalidated every token already on disk,
and the only symptom was git prompting for a password nobody has. cred_lookup
falls back to the old host-plus-path key once and rewrites it under the new one,
so the migration happens on first use rather than by asking everybody to re-join.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
http-backend exits 0 and sends 200 when the pre-receive gate declines: the
refusal travels in the sideband, not in the status line. So every rejected push
was logged as a successful one, and the log could not answer the single question
it is kept for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The catch-all turns anything unforeseen into a 500, but once a status line is on
the wire a second response is not a refusal: it is appended to the body of the
first, and the client parses it as content. The handler now tracks whether it
has sent a status line and the catch-all only logs when it has.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The CGI whitelist, the catch-all 500, the read timeout and the newline in a
directory name were each argued for in a comment and checked by nothing. Now: a
GIT_DIR in the daemon environment does not follow the request into the backend;
an unforeseen error is a 500 with a body rather than a dropped connection; the
timeout is on the socket, not just on the class; and a tree built with
`git mktree -z` around a directory name containing a newline is still validated,
which the line-oriented loop it replaced accepted unchecked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Old keys carried no scheme, so `owner://h:8899` and `https://h:8899` collapse to
the same string. Any plain `git fetch` at the bare host, which is every repo
without credential.useHttpPath, matched the owner line and got the key to every
graph on the server; the migration then rewrote it as `https://h:8899`, where
ordinary git could reach it forever after. Reproduced against the previous
commit: the helper answered `username=owner password=SERVER-WIDE-SECRET`.

The whole point of the `owner://` namespace is that no git request can name it,
and a lookup that strips the scheme is exactly a lookup that can. The fallback
existed for one release-less week, so nothing is stranded by removing it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Pinning SERVER_GIT_ENV inside install_server fixed the served case and broke the
manual one. `knoten hook --server` runs on a repo nginx or sshd hosts under the
operator's own account, whose receive-pack reads their ~/.gitconfig: the gate
was written to repo.git/hooks while git went looking at their core.hooksPath,
and a broken push landed with rc 0.

The environment is now the caller's to name. Registry.create passes
SERVER_GIT_ENV, because knoten serve runs receive-pack itself and under exactly
that; the CLI passes nothing, like the client-side hook next to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The refused/landed verdict was grepped out of the entire response body, which
carries the hook's stderr on band 2, and the hook echoes back the paths in the
pushed tree. A graph in a directory named `denying x` made a push that landed
log as refused, and every phrase the grep looked for is chosen by whoever is
pushing. The response is now parsed as pkt-lines and only band 1, which is
report-status and nothing else, is read: `ng <ref>` is a rejection, and a ref
name cannot contain a space. A body with no band bytes is report-status itself.

Also: the comment on the _answered reset said a connection serves more than one
request. It is HTTP/1.0, so it does not; the reset is for the day keep-alive is
turned on, and the comment now says that.

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

socketserver prints a whole traceback block for anything that gets past the
handler, which breaks the rule that nobody reads a stack to find out what went
wrong and is unreadable next to the access log. Two things reach it: a client
that closed before the response was written, and a handler thread outliving the
request that started it, since these are daemon threads and shutdown does not
join them.

That second case is also why the crashed-backend test now replaces knoten's own
bound name rather than subprocess.run: patching the module handed the fake to
every thread in the process, and a straggler from an earlier test could pick it
up. One full-suite run in twenty printed a traceback that way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
git() merged GIT_ISOLATION into the subprocesses IT ran, which protected nothing
knoten runs itself: install_server asks git where hooks live from inside the
module under test. Under a global core.hooksPath the suite answered with the
developer's shared hooks directory, wrote a pre-commit and a pre-receive into
it, and 24 tests failed after touching a directory outside the tmp_path they
were given. An autouse fixture now sets the isolation in os.environ; the merge
in git() stays.

hooks_path_hub undoes it for the server side on purpose. That fixture exists to
put a global core.hooksPath in front of the code, and pinning it away made the
test pass whether or not Registry.create passed SERVER_GIT_ENV to
install_server. It now fails when that argument is removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
@BY571 BY571 changed the title knoten hook --server: a gate on the repo everyone pushes to shared graphs: the gate on the server, and knoten serve for remotes friends can be invited to Sep 5, 2026
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