coverage~bigbes/sr-ht-spec3cb1c03dhooks/server.go

Coverage
87.4% 188/215 statements
Δ
Blob
f13e19a
1 package hooks
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "net"
9 "net/http"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "time"
16
17 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
18 "sourcecraft.dev/bigbes/sr-ht-spec/core"
19 "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
20 "sourcecraft.dev/bigbes/sr-ht-spec/service"
21 )
22
23 // Backend is the daemon this package fronts. *service.Service satisfies it as
24 // written; it is an interface so the receive path can be exercised end to end,
25 // against a real `git push`, without a Postgres instance.
26 //
27 // Everything policy-shaped lives behind ValidatePush. This package decides
28 // which space a repository is, who is pushing, and whether validation was
29 // waived — and then asks. That split is the point of the whole design: the API
30 // and the push path must not be able to disagree about what is valid.
31 type Backend interface {
32 // ReposRoot is [spec.sr.ht] repos, the only directory a hook may address a
33 // repository under.
34 ReposRoot() string
35
36 // Resolver carries the instance owner username — the one identity an
37 // "owner" credential can resolve to — and the tokens.sr.ht plane an agent's
38 // credential is validated on. It is the same resolver the HTTP surfaces
39 // authenticate through, which is the point: one plane, one implementation of
40 // "is this credential good?".
41 Resolver() *authn.Resolver
42
43 // ValidatePush answers whether one ref may move. A *service.PushRejection
44 // is a policy refusal whose Error() is the text to print; anything else is
45 // an infrastructure failure and must fail the push closed.
46 ValidatePush(ctx context.Context, req service.PushRequest) error
47 }
48
49 // PushNotifier is what the daemon does when a push lands: reindex the changed
50 // documents and advance the space's index rev stamp.
51 //
52 // It is injected rather than being a Backend method because Phase 1 has no
53 // indexer — bleve and the index rev stamp belong to Phase 2 — and a stamp
54 // advanced without a reindex behind it would be a lie the reconciler could not
55 // detect. Until then the daemon supplies a notifier that records the push and
56 // nothing else, and the reconciler reports the staleness.
57 type PushNotifier func(ctx context.Context, space core.SpaceRef, updates []RefUpdate) error
58
59 // Options configures a Server.
60 type Options struct {
61 // Backend and Socket are required.
62 Backend Backend
63 Socket string
64
65 // OnPush is called for each landed push. Required: a server with no
66 // notifier would accept post-receive calls and drop them, which reads
67 // exactly like a working index that never updates.
68 OnPush PushNotifier
69
70 // Log defaults to slog.Default().
71 Log *slog.Logger
72
73 // Timeout bounds handling one request. Zero means DefaultTimeout.
74 Timeout time.Duration
75
76 // OptionTTL is how long a push's recorded options survive without the
77 // matching update calls. Zero means DefaultOptionTTL.
78 OptionTTL time.Duration
79 }
80
81 const (
82 // DefaultOptionTTL is how long the daemon remembers a push's options. The
83 // gap between pre-receive and the last update of one push is milliseconds;
84 // this is three orders of magnitude of slack, and the only cost of an
85 // entry outliving its push is a few hundred bytes until it is swept.
86 DefaultOptionTTL = 10 * time.Minute
87
88 // maxPendingPushes caps the option table. Any local process can connect to
89 // the socket, so the table must not be a way to exhaust memory; at the
90 // documented volume — one human, tens of documents a day — a thousand
91 // pushes in flight at once means something is wrong, and failing closed is
92 // the right answer to that.
93 maxPendingPushes = 1024
94
95 // socketDirMode keeps the socket's directory private to the service user.
96 // The socket is an unauthenticated write path to the daemon: anyone who
97 // can connect can assert the owner principal.
98 socketDirMode = 0o700
99 socketMode = 0o600
100 )
101
102 // Server is the daemon side of the receive path: a unix socket the hooks call.
103 //
104 // The socket is unix-domain and not a TCP port on localhost, for one reason
105 // that decides it: filesystem permissions. An "owner" credential is an
106 // assertion, so the ability to connect is the ability to push as the owner —
107 // a 0700 directory holding a 0600 socket makes that reachable only by the
108 // service user, which no localhost TCP port can do.
109 type Server struct {
110 backend Backend
111 socket string
112 log *slog.Logger
113 onPush PushNotifier
114 timeout time.Duration
115 optionTTL time.Duration
116
117 listener net.Listener
118 wg sync.WaitGroup
119
120 // closing distinguishes a listener we shut down from one that failed. Both
121 // surface as net.ErrClosed out of Accept, and only one of them is an error.
122 closing atomic.Bool
123
124 mu sync.Mutex
125 pending map[string]*pendingPush
126 }
127
128 // pendingPush is what pre-receive told us about a push, waiting for the update
129 // calls it belongs to.
130 type pendingPush struct {
131 updates []RefUpdate
132 skip bool
133 expires time.Time
134 }
135
136 // NewServer builds a server. It does not listen; call Listen.
137 37 func NewServer(opts Options) (*Server, error) {
138 37 if opts.Backend == nil {
139 1 return nil, errors.New("hooks: no backend")
140 1 }
141 36 if opts.Socket == "" {
142 1 return nil, errors.New("hooks: no socket path")
143 1 }
144 35 if !filepath.IsAbs(opts.Socket) {
145 1 return nil, fmt.Errorf("hooks: socket path %q is not absolute", opts.Socket)
146 1 }
147 34 if opts.OnPush == nil {
148 1 return nil, errors.New("hooks: no push notifier")
149 1 }
150 33 if opts.Backend.ReposRoot() == "" {
151 0 return nil, errors.New("hooks: backend has no repos root")
152 0 }
153 33 log := opts.Log
154 33 if log == nil {
155 1 log = slog.Default()
156 1 }
157 33 timeout := opts.Timeout
158 33 if timeout <= 0 {
159 3 timeout = DefaultTimeout
160 3 }
161 33 ttl := opts.OptionTTL
162 33 if ttl <= 0 {
163 32 ttl = DefaultOptionTTL
164 32 }
165 33 return &Server{
166 33 backend: opts.Backend,
167 33 socket: opts.Socket,
168 33 log: log,
169 33 onPush: opts.OnPush,
170 33 timeout: timeout,
171 33 optionTTL: ttl,
172 33 pending: make(map[string]*pendingPush),
173 33 }, nil
174 }
175
176 // Socket is the path this server listens on.
177 35 func (s *Server) Socket() string { return s.socket }
178
179 // Listen binds the socket.
180 //
181 // A leftover socket file from a crashed daemon is removed, but only after
182 // proving it is dead: if something answers on it, another daemon is running
183 // and this one refuses to start rather than stealing the push path from it.
184 32 func (s *Server) Listen() error {
185 32 if err := os.MkdirAll(filepath.Dir(s.socket), socketDirMode); err != nil {
186 0 return fmt.Errorf("hooks: create %s: %w", filepath.Dir(s.socket), err)
187 0 }
188 // The directory may pre-date this version, or have been created with a
189 // looser umask; make it private either way.
190 32 if err := os.Chmod(filepath.Dir(s.socket), socketDirMode); err != nil {
191 0 return fmt.Errorf("hooks: restrict %s: %w", filepath.Dir(s.socket), err)
192 0 }
193
194 32 if _, err := os.Stat(s.socket); err == nil {
195 2 conn, dialErr := net.DialTimeout("unix", s.socket, time.Second)
196 2 if dialErr == nil {
197 1 conn.Close()
198 1 return fmt.Errorf("hooks: %s is already served by another process", s.socket)
199 1 }
200 1 if err := os.Remove(s.socket); err != nil {
201 0 return fmt.Errorf("hooks: remove the stale socket %s: %w", s.socket, err)
202 0 }
203 1 s.log.Warn("removed a stale hook socket", "socket", s.socket, "dial_error", dialErr)
204 30 } else if !errors.Is(err, os.ErrNotExist) {
205 0 return fmt.Errorf("hooks: stat %s: %w", s.socket, err)
206 0 }
207
208 31 ln, err := net.Listen("unix", s.socket)
209 31 if err != nil {
210 0 return fmt.Errorf("hooks: listen on %s: %w", s.socket, err)
211 0 }
212 31 if err := os.Chmod(s.socket, socketMode); err != nil {
213 0 ln.Close()
214 0 return fmt.Errorf("hooks: restrict %s: %w", s.socket, err)
215 0 }
216 31 s.listener = ln
217 31 return nil
218 }
219
220 // Serve accepts hook connections until ctx is cancelled, then waits for the
221 // calls already in flight. Listen must have succeeded first.
222 30 func (s *Server) Serve(ctx context.Context) error {
223 30 if s.listener == nil {
224 0 return errors.New("hooks: Serve called before Listen")
225 0 }
226
227 30 done := make(chan struct{})
228 30 defer close(done)
229 30 go func() {
230 30 select {
231 29 case <-ctx.Done():
232 29 s.listener.Close()
233 case <-done:
234 }
235 }()
236
237 95 for {
238 95 conn, err := s.listener.Accept()
239 95 if err != nil {
240 30 s.wg.Wait()
241 30 if ctx.Err() != nil || s.closing.Load() {
242 30 return nil
243 30 }
244 0 return fmt.Errorf("hooks: accept on %s: %w", s.socket, err)
245 }
246 65 s.wg.Add(1)
247 65 go func() {
248 65 defer s.wg.Done()
249 65 s.serveConn(ctx, conn)
250 65 }()
251 }
252 }
253
254 // Close stops listening and removes the socket. It is idempotent, and safe to
255 // call after Serve has already returned: a daemon shuts down by cancelling
256 // Serve's context and then calling this, and a daemon that failed to start
257 // after Listen calls only this.
258 32 func (s *Server) Close() error {
259 32 if s.listener == nil {
260 0 return nil
261 0 }
262 32 s.closing.Store(true)
263 32 err := s.listener.Close()
264 32 if errors.Is(err, net.ErrClosed) {
265 30 err = nil
266 30 }
267 32 s.wg.Wait()
268 32 // net's unix listener unlinks the socket itself; removing it again is
269 32 // tolerated so a listener built elsewhere is still cleaned up.
270 32 if rmErr := os.Remove(s.socket); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
271 0 if err == nil {
272 0 err = fmt.Errorf("hooks: remove %s: %w", s.socket, rmErr)
273 0 }
274 }
275 32 return err
276 }
277
278 65 func (s *Server) serveConn(ctx context.Context, conn net.Conn) {
279 65 defer conn.Close()
280 65
281 65 ctx, cancel := context.WithTimeout(ctx, s.timeout)
282 65 defer cancel()
283 65 if deadline, ok := ctx.Deadline(); ok {
284 65 // The context bounds the handler, not the socket reads; without a
285 65 // deadline on the connection a peer that connects and says nothing
286 65 // holds a goroutine forever.
287 65 if err := conn.SetDeadline(deadline); err != nil {
288 0 s.log.Error("could not bound a hook connection", "socket", s.socket, "error", err)
289 0 return
290 0 }
291 }
292
293 65 req, err := ReadRequest(conn)
294 65 if err != nil {
295 2 s.log.Error("unreadable hook request", "socket", s.socket, "error", err)
296 2 // The peer may be something that is not a hook at all; answer anyway,
297 2 // so a hook that sent a message we could not parse still gets a
298 2 // refusal rather than a closed connection it has to interpret.
299 2 s.reply(conn, errorResponse("the daemon could not read the request: %v", err))
300 2 return
301 2 }
302
303 63 resp := s.handle(ctx, req)
304 63 s.reply(conn, resp)
305 }
306
307 65 func (s *Server) reply(conn net.Conn, resp Response) {
308 65 if err := WriteResponse(conn, resp); err != nil {
309 1 s.log.Error("could not answer a hook", "socket", s.socket, "error", err)
310 1 }
311 }
312
313 // handle answers one request. It never panics a connection into the pusher's
314 // terminal: every failure becomes a Response the hook knows how to print.
315 63 func (s *Server) handle(ctx context.Context, req Request) Response {
316 63 if err := req.Validate(); err != nil {
317 0 s.log.Error("malformed hook request", "method", req.Method, "error", err)
318 0 return errorResponse("malformed %s request: %v", req.Method, err)
319 0 }
320
321 63 space, err := s.spaceFor(req.Repo)
322 63 if err != nil {
323 6 s.log.Error("hook named a repository we do not own", "repo", req.Repo, "error", err)
324 6 return errorResponse("%v", err)
325 6 }
326
327 57 principal, resp, ok := s.principal(ctx, space, req.Credential)
328 57 if !ok {
329 8 return resp
330 8 }
331
332 49 log := s.log.With(
333 49 "space", space.String(),
334 49 "principal", principal.String(),
335 49 "push", req.Push,
336 49 "method", string(req.Method),
337 49 )
338 49
339 49 switch req.Method {
340 22 case MethodPushOptions:
341 22 return s.handlePushOptions(req, space, log)
342 21 case MethodValidateRef:
343 21 return s.handleValidateRef(ctx, req, space, principal, log)
344 6 case MethodPushed:
345 6 return s.handlePushed(ctx, req, space, log)
346 0 default:
347 0 // Request.Validate already rejected anything else.
348 0 return errorResponse("unhandled method %q", req.Method)
349 }
350 }
351
352 // handlePushOptions records what pre-receive saw, and refuses a push option
353 // this service does not understand.
354 22 func (s *Server) handlePushOptions(req Request, space core.SpaceRef, log *slog.Logger) Response {
355 22 if unknown := UnknownOptions(req.Options); len(unknown) > 0 {
356 2 log.Info("refused unknown push options", "options", unknown)
357 2 return rejectedResponse(unknownOptionMessage(space, unknown))
358 2 }
359 20 skip := SkipValidation(req.Options)
360 20 if err := s.remember(req, skip); err != nil {
361 0 log.Error("could not record push options", "error", err)
362 0 return errorResponse("%v", err)
363 0 }
364 20 log.Info("push received", "refs", len(req.Updates), "skip_validation", skip)
365 20 return okResponse()
366 }
367
368 // handleValidateRef is the whole of the rejecting path: the refs rule, then
369 // frontmatter and document-id validation, both inside service.ValidatePush so
370 // the push path and the API cannot drift apart.
371 func (s *Server) handleValidateRef(ctx context.Context, req Request, space core.SpaceRef,
372 21 principal authn.Principal, log *slog.Logger) Response {
373 21
374 21 update := req.Updates[0]
375 21 skip, err := s.recall(req, update)
376 21 if err != nil {
377 3 log.Error("no recorded pre-receive phase for this push", "ref", update.Ref, "error", err)
378 3 return errorResponse("%v", err)
379 3 }
380
381 18 err = s.backend.ValidatePush(ctx, service.PushRequest{
382 18 Space: space,
383 18 Principal: principal,
384 18 Ref: update.Ref,
385 18 Old: update.Old,
386 18 New: update.New,
387 18 SkipValidation: skip,
388 18 })
389 18 var rejection *service.PushRejection
390 18 switch {
391 11 case err == nil:
392 11 log.Info("ref accepted", "ref", update.Ref, "skip_validation", skip)
393 11 return okResponse()
394 6 case errors.As(err, &rejection):
395 6 log.Info("ref rejected", "ref", update.Ref, "problems", len(rejection.Problems),
396 6 "skippable", rejection.Skippable)
397 6 return rejectedResponse(rejection.Error())
398 1 default:
399 1 // Not a policy answer: Postgres down, an unreadable object, a space
400 1 // with no row. The hook fails the push closed on it.
401 1 log.Error("could not validate a ref", "ref", update.Ref, "error", err)
402 1 return errorResponse("spec.sr.ht could not validate %s: %v", update.Ref, err)
403 }
404 }
405
406 // handlePushed notifies the daemon that refs moved. Its answer cannot stop
407 // anything — git ignores post-receive's exit status — but it is still reported
408 // honestly so the hook can warn that the index is stale.
409 6 func (s *Server) handlePushed(ctx context.Context, req Request, space core.SpaceRef, log *slog.Logger) Response {
410 6 s.forget(req)
411 6 if err := s.onPush(ctx, space, req.Updates); err != nil {
412 1 log.Error("could not record a landed push", "refs", len(req.Updates), "error", err)
413 1 return errorResponse("%v", err)
414 1 }
415 5 log.Info("push landed", "refs", len(req.Updates))
416 5 return okResponse()
417 }
418
419 // spaceFor turns the repository a hook is running in into a space.
420 //
421 // The hook's claim is never taken at face value: the path is matched against
422 // this daemon's own repos root and then re-derived through gitx.DiskPath, so
423 // the only paths that resolve are the ones this daemon would itself have
424 // created. gitx is the single source of truth for that layout — deriving it
425 // twice is how the two copies drift.
426 63 func (s *Server) spaceFor(repo string) (core.SpaceRef, error) {
427 63 root, err := filepath.EvalSymlinks(s.backend.ReposRoot())
428 63 if err != nil {
429 0 return core.SpaceRef{}, fmt.Errorf("the repos root %s is unreadable: %w",
430 0 s.backend.ReposRoot(), err)
431 0 }
432 63 root, err = filepath.Abs(root)
433 63 if err != nil {
434 0 return core.SpaceRef{}, fmt.Errorf("the repos root %s is unresolvable: %w",
435 0 s.backend.ReposRoot(), err)
436 0 }
437
438 63 clean := filepath.Clean(repo)
439 63 rel, err := filepath.Rel(root, clean)
440 63 if err != nil {
441 0 return core.SpaceRef{}, fmt.Errorf("%s is not under the repos root %s", repo, root)
442 0 }
443 63 segs := strings.Split(rel, string(filepath.Separator))
444 63 if len(segs) != 2 || segs[0] == ".." || !strings.HasPrefix(segs[0], "~") {
445 6 return core.SpaceRef{}, fmt.Errorf("%s is not a space repository; "+
446 6 "this daemon serves %s/~<owner>/<space> only", repo, root)
447 6 }
448 57 ref, err := core.ParseSpaceRef(rel)
449 57 if err != nil {
450 0 return core.SpaceRef{}, fmt.Errorf("%s does not name a valid space: %w", repo, err)
451 0 }
452 57 if want := gitx.DiskPath(root, ref); want != clean {
453 0 return core.SpaceRef{}, fmt.Errorf("%s does not name a space; %s would live at %s",
454 0 repo, ref, want)
455 0 }
456 57 return ref, nil
457 }
458
459 // principal resolves the credential a hook forwarded.
460 //
461 // The owner is not looked up: sshd authenticated the SSH key and the forced
462 // command asserted it, and there is exactly one owner on this instance, so the
463 // name comes from the resolver rather than from the wire — a hook cannot name
464 // somebody else. An agent's credential is checked on every push.
465 //
466 // An agent goes through authn.Resolver.ResolveAgent, the same call the HTTP
467 // surfaces reach through their middleware. This path used to read the
468 // agent_token table directly, which is exactly how it ended up accepting a
469 // credential the HTTP planes had already stopped being the only door for: two
470 // implementations of one question, drifting. There is one credential plane now
471 // and one implementation of checking it.
472 //
473 // The grant check is here rather than in ValidatePush because it is about the
474 // credential and not about the ref: a push by an agent is a proposal by another
475 // transport, so it needs spec:propose exactly as the REST and MCP write planes
476 // do. The refs rule still runs afterwards, inside ValidatePush, and still
477 // confines the agent to proposals/* — a grant does not replace it and cannot
478 // widen it.
479 57 func (s *Server) principal(ctx context.Context, space core.SpaceRef, cred Credential) (authn.Principal, Response, bool) {
480 57 owner := s.backend.Resolver().Owner()
481 57 switch cred.Kind {
482 42 case PrincipalOwner:
483 42 return authn.Principal{Kind: authn.KindOwner, Owner: owner, CookieUser: owner}, Response{}, true
484 15 case PrincipalAgent:
485 15 p, err := s.backend.Resolver().ResolveAgent(ctx, cred.Token, cred.Agent, cred.Session)
486 15 if err != nil {
487 5 // StatusFor is the one table: a bad or foreign credential (401) and
488 5 // a good one this instance has nothing to grant (403) are policy
489 5 // refusals the pusher can read and act on; anything else means we
490 5 // could not check, and an unanswerable check fails the push closed
491 5 // rather than reading as a bad token.
492 5 if status := authn.StatusFor(err); status < http.StatusInternalServerError {
493 4 s.log.Warn("refused an agent push", "space", space.String(), "error", err)
494 4 return authn.Principal{}, rejectedResponse(badTokenMessage(space, err)), false
495 4 }
496 1 s.log.Error("could not validate an agent credential", "space", space.String(), "error", err)
497 1 return authn.Principal{}, errorResponse(
498 1 "spec.sr.ht could not check the credential presented with this push: %v", err), false
499 }
500 10 if err := p.Authorize(authn.ActionPropose); err != nil {
501 3 s.log.Warn("refused an agent push", "space", space.String(), "error", err)
502 3 return authn.Principal{}, rejectedResponse(badTokenMessage(space, err)), false
503 3 }
504 7 return p, Response{}, true
505 0 default:
506 0 // Request.Validate rejected every other spelling already.
507 0 return authn.Principal{}, errorResponse("unknown principal kind %q", cred.Kind), false
508 }
509 }
510
511 // pendingKey identifies one push: the repository plus the pid of the
512 // receive-pack process every hook of that push is a child of.
513 47 func pendingKey(req Request) string { return req.Repo + "\x00" + req.Push }
514
515 // remember stores what pre-receive saw. It sweeps expired entries first, and
516 // refuses rather than growing without bound.
517 20 func (s *Server) remember(req Request, skip bool) error {
518 20 now := time.Now()
519 20 s.mu.Lock()
520 20 defer s.mu.Unlock()
521 20 s.sweepLocked(now)
522 20 if len(s.pending) >= maxPendingPushes {
523 0 return fmt.Errorf("spec.sr.ht is already tracking %d pushes in flight and cannot accept another",
524 0 len(s.pending))
525 0 }
526 20 s.pending[pendingKey(req)] = &pendingPush{
527 20 updates: append([]RefUpdate(nil), req.Updates...),
528 20 skip: skip,
529 20 expires: now.Add(s.optionTTL),
530 20 }
531 20 return nil
532 }
533
534 // recall answers whether validation was waived for this ref.
535 //
536 // The pre-receive record is required, not optional. Absence is not read as
537 // "not waived": it means either that the repository's hooks are half installed
538 // — no pre-receive, so no push option would ever be seen and skip-validation
539 // would silently never work — or that the daemon restarted mid-push. Both
540 // deserve a sentence rather than a guess.
541 //
542 // The recorded ref update must also match this one exactly. That is what makes
543 // the pid safe as a correlation key: a recycled pid would have to be paired
544 // with an identical ref, old and new object name to be mistaken for this push.
545 21 func (s *Server) recall(req Request, update RefUpdate) (bool, error) {
546 21 now := time.Now()
547 21 s.mu.Lock()
548 21 defer s.mu.Unlock()
549 21 s.sweepLocked(now)
550 21
551 21 entry, ok := s.pending[pendingKey(req)]
552 21 if !ok {
553 2 return false, fmt.Errorf("the daemon did not see the pre-receive phase of this push. "+
554 2 "Either the repository's hooks are only partly installed (all of %s must be present) "+
555 2 "or the daemon restarted mid-push; push again",
556 2 strings.Join(modeNames(), ", "))
557 2 }
558 19 for _, u := range entry.updates {
559 19 if u == update {
560 18 return entry.skip, nil
561 18 }
562 }
563 1 return false, fmt.Errorf("the pre-receive phase of push %s did not announce %s; "+
564 1 "the daemon will not validate a ref it was not told about", req.Push, update)
565 }
566
567 // forget drops a push's record once post-receive has run.
568 6 func (s *Server) forget(req Request) {
569 6 s.mu.Lock()
570 6 defer s.mu.Unlock()
571 6 delete(s.pending, pendingKey(req))
572 6 s.sweepLocked(time.Now())
573 6 }
574
575 47 func (s *Server) sweepLocked(now time.Time) {
576 47 for k, v := range s.pending {
577 40 if now.After(v.expires) {
578 1 delete(s.pending, k)
579 1 }
580 }
581 }
582
583 2 func modeNames() []string {
584 2 out := make([]string, 0, len(Modes()))
585 6 for _, m := range Modes() {
586 6 out = append(out, string(m))
587 6 }
588 2 return out
589 }
590
591 // unknownOptionMessage is what a mistyped push option prints. It is a
592 // rejection rather than a shrug because there is exactly one option in the
593 // vocabulary: silently ignoring "--push-option=skip-validaton" would reject
594 // the push for the very thing the human believed they had waived.
595 2 func unknownOptionMessage(space core.SpaceRef, unknown []string) string {
596 2 var b strings.Builder
597 2 fmt.Fprintf(&b, "spec.sr.ht rejected this push.\n\n")
598 2 fmt.Fprintf(&b, " space: %s\n\n", space)
599 2 for _, o := range unknown {
600 2 fmt.Fprintf(&b, " --push-option=%s is not a push option this service knows\n", o)
601 2 }
602 2 fmt.Fprintf(&b, "\nThe only push option is --push-option=%s, which waives\n", OptionSkipValidation)
603 2 fmt.Fprintf(&b, "frontmatter and document-id validation. Nothing was written.\n")
604 2 return b.String()
605 }
606
607 // badTokenMessage is what an agent sees when its credential does not
608 // authenticate or does not carry spec:propose. It never echoes the token.
609 7 func badTokenMessage(space core.SpaceRef, cause error) string {
610 7 var b strings.Builder
611 7 fmt.Fprintf(&b, "spec.sr.ht rejected this push.\n\n")
612 7 fmt.Fprintf(&b, " space: %s\n\n", space)
613 7 fmt.Fprintf(&b, " the agent credential presented with this push was refused:\n")
614 7 fmt.Fprintf(&b, " %v\n\n", cause)
615 7 fmt.Fprintf(&b, "Nothing was written. Agent credentials are tokens.sr.ht working tokens,\n")
616 7 fmt.Fprintf(&b, "the same ones the REST and MCP planes take, and a push needs the\n")
617 7 fmt.Fprintf(&b, "spec:propose grant just as those do.\n")
618 7 return b.String()
619 7 }