authn: accept tokens.sr.ht working tokens beside the agent token
A second agent credential plane, next to the existing one rather than in
place of it. The agent_token table, every agent configured with it, and
the refs rule and provenance requirement around it are untouched; the
local plane is removed in a later phase, not this one.
The resolver tries the instance plane first and falls back to the local
store on exactly two refusals, bearer.ErrInvalid and bearer.ErrNotOurs.
spec's local token has no prefix to discriminate on — it is 32 random
bytes in base64, which is precisely what "did not decode as one of ours"
looks like — so the fallback replaces the shape test bench and cover can
afford. ErrRevoked, ErrForbidden and ErrUnavailable are terminal: a
withdrawn credential must not get a second chance at the old door, and an
unreachable daemon must not silently degrade into the legacy plane.
Grants ride on the principal and are checked where the action is known,
never in the middleware, which runs upstream of the router: spec:propose
in service.Propose, below both write surfaces, and spec:read in each read
surface's gate. /mcp checks per tool rather than at its Gate, because one
endpoint carries both kinds and a surface-wide read grant would refuse a
propose-only token at initialize. Principal.Authorize is a no-op off the
instance plane, which is what keeps the local token working.
The instance plane brings an owner where the local token had none, so a
working token belonging to anybody but [sr.ht] owner-name is refused
rather than admitted as a second identity: Principal.Owner is read by the
provenance committer, the refs rule's principal kind and the coreauth
AuthContext, all written for one human.
StatusFor is the one status table. ErrUnavailable is 503 and never 401 —
reading "I could not ask tokens.sr.ht" as "revoked" would refuse every
live instance token while a daemon that is deliberately off the hot path
restarts.
An instance with no [tokens.sr.ht] section builds no instance plane and
starts anyway, serving its own agent token as before.
feat: hooks — the receive path, its daemon RPC, and the specsrht daemon
The hooks are thin shims that RPC into the running daemon over a unix
socket under the repos root, so validation lives in one place and the
push path cannot drift from the API. They fail closed: an unreachable
daemon rejects the push.
Three hooks are installed, not two, because of two properties of git
verified against 2.55 rather than inferred:
- GIT_PUSH_OPTION_* reaches pre-receive and post-receive only; the
update hook observably runs without them, so it cannot see
--push-option=skip-validation on its own.
- during pre-receive the pushed objects are still in receive-pack's
quarantine and unreadable by any other process, so the daemon
cannot validate there. git migrates them out before the first
update hook, which makes update the earliest hook that works.
pre-receive therefore forwards the push options and the ref list; the
daemon records them keyed by (repository, receive-pack pid) and by the
exact ref update; update does the rejecting; post-receive notifies.
An update with no recorded pre-receive phase is refused rather than
assumed unskippable.
Each hook is a symlink to the specsrht binary, which dispatches on the
name git invoked it as. Install also sets receive.advertisePushOptions,
without which the escape hatch fails client-side. The daemon refreshes
every space's hooks at startup and treats a failure as fatal.
cmd/specsrht validates every config key at once before opening
anything, serves /healthz on -b (default localhost:5091), runs the
reconciler, and warm-shuts-down on SIGINT and SIGTERM alike by
bridging SIGTERM into the SIGINT core-go's server.Run waits for.
Tested with a real git push against a real bare repo with the hooks
installed: a valid push lands, bad frontmatter is rejected with a
readable message, skip-validation waives it, a force-push to the
approved branch is refused with or without skip-validation, a mistyped
push option is refused rather than ignored, and a push with no daemon
is rejected.