···11+# Plan: Git over SSH + a generic auth seam for objgitd
22+33+> **For agentic workers:** implement task-by-task in build order (bottom of this
44+> doc). Steps are checkboxes (`- [ ]`) for tracking. TDD where a real `git`
55+> client can drive it; gate those tests on `exec.LookPath("git")` like the
66+> existing suites.
77+88+**Goal:** add a third Git transport — **SSH** (`ssh://host/repo.git`) — and, in
99+the same change, introduce a transport-neutral **`internal/auth`** authorization
1010+seam that all three transports (git://, HTTP, SSH) funnel through, replacing the
1111+scattered `-allow-push` checks with one pluggable `Authorizer`.
1212+1313+## Context
1414+1515+`objgitd` serves repositories out of a Tigris/S3-backed billy filesystem over
1616+**git:// (TCP)** (`cmd/objgitd/git_protocol.go`) and **smart-HTTP**
1717+(`cmd/objgitd/http.go`). Both wrap one `*daemon` (`fs`, `loader`, `loadOrInit`,
1818+`receivePack`) and answer the protocol **natively** with go-git's
1919+`transport.UploadPack` / `ReceivePack` / `UploadArchive` — there is **no `git`
2020+binary and no on-disk checkout**.
2121+2222+This is the key difference from charmbracelet/wish's `git/git.go` (the reference
2323+the user started from): wish `exec`s the real `git-upload-pack` binary against
2424+an on-disk repo. We do **not** do that. The SSH handler is a third sibling to
2525+`git_protocol.go` / `http.go`: read the requested command, resolve the repo,
2626+hand the SSH session's stdin/stdout to the same `transport.*` functions. From
2727+wish we keep only the SSH-side mechanics (command → service mapping, path
2828+cleaning, session wiring).
2929+3030+SSH is, like git://, a **persistent bidirectional stream** (not HTTP's
3131+request/response), so the two git:// protocol workarounds apply verbatim and the
3232+HTTP path's assumptions do **not** — see [Two protocol gotchas](#two-protocol-gotchas-ssh--git-not-http).
3333+3434+### Why a generic auth seam
3535+3636+Each transport carries a different credential, and HTTP needs a challenge flow
3737+the others don't:
3838+3939+| Transport | Credential it presents | "please authenticate" mechanism |
4040+| --------- | ----------------------------- | ------------------------------------- |
4141+| git:// | none (anonymous) | n/a |
4242+| SSH | public key (validated at connect) | handled in the SSH auth handshake |
4343+| HTTP | `Authorization: Basic …` / none | `401` + `WWW-Authenticate` |
4444+4545+The transport must **not** validate credentials itself (a password is only
4646+checkable against a user store the *policy* owns). Each transport's job shrinks
4747+to three things: **collect** the raw credential, **map** the git service to a
4848+read/write `Operation`, and **enforce** the returned `Decision` in its own
4949+dialect. All real auth logic lives behind one interface, so the same mechanism
5050+serves HTTP basic auth, SSH keys, and any future scheme (bearer tokens, mTLS).
5151+5252+Decisions from the user:
5353+5454+- **One generic `Authorizer` interface**, credential-agnostic on input, with a
5555+ `Decision` expressive enough that HTTP can render a 401 challenge while
5656+ SSH/git:// just allow-or-deny.
5757+- **Retrofit all three transports now**, replacing the three scattered
5858+ `-allow-push` checks with a single default authorizer
5959+ `auth.AllowAnonymous{AllowWrite: *allowPush}`.
6060+- **Stub permissive.** The only implementation today is `AllowAnonymous`; a real
6161+ authn/authz layer plugs in later without touching transport code.
6262+- **Host key persisted in the bucket** (`.objgit/ssh_host_ed25519_key`), so SSH
6363+ needs no operator config and survives restarts without host-key-changed warnings.
6464+- **Opt-in SSH.** New `-ssh-bind` flag defaults to `""` (disabled), matching git://.
6565+6666+Code/test conventions follow `xe-go:xe-go-style` and `xe-go:go-table-driven-tests`
6767+(flagenv→flag, kebab-case flags, slog with `"err"` key, errgroup server
6868+lifecycle, `t.Helper()` helpers, table-driven subtests with `tt`).
6969+7070+## Files
7171+7272+| Action | Path | Purpose |
7373+| ------ | ----------------------------- | ------------------------------------------------------ |
7474+| Create | `internal/auth/auth.go` | transport-neutral `Authorizer`, `Credential`, `Decision`, `AllowAnonymous` |
7575+| Create | `internal/auth/auth_test.go` | unit tests for `AllowAnonymous` decisions |
7676+| Create | `cmd/objgitd/ssh.go` | SSH server, host key, command dispatch |
7777+| Create | `cmd/objgitd/ssh_test.go` | table-driven clone/push/deny/create/hook tests |
7878+| Edit | `cmd/objgitd/git_protocol.go` | `authz` field on `daemon`; git:// goes through authz |
7979+| Edit | `cmd/objgitd/http.go` | HTTP goes through authz (Basic-auth parse, 401/403) |
8080+| Edit | `cmd/objgitd/main.go` | `-ssh-bind` flag, default authz, SSH errgroup listener |
8181+| Edit | `go.mod` / `go.sum` | add `github.com/gliderlabs/ssh` (direct) |
8282+| Edit | `CLAUDE.md` | document the auth seam + third transport |
8383+8484+The `daemon` struct stays the shared backend. The `allowPush bool` field is
8585+**removed** from `daemon` (the flag survives in `main.go` only, to build the
8686+default authorizer); SSH methods live in `ssh.go`.
8787+8888+## internal/auth package
8989+9090+Transport-neutral on purpose: it imports only `context` and
9191+`golang.org/x/crypto/ssh` (for the public-key wire type), **not** gliderlabs/ssh
9292+or go-git. Transports translate their own world into `auth.Request` and act on
9393+`auth.Decision`.
9494+9595+```go
9696+package auth
9797+9898+import (
9999+ "context"
100100+101101+ gossh "golang.org/x/crypto/ssh"
102102+)
103103+104104+// Operation is the access a request needs. Transports map the git service:
105105+// upload-pack/upload-archive → Read, receive-pack → Write.
106106+type Operation int
107107+108108+const (
109109+ Read Operation = iota
110110+ Write
111111+)
112112+113113+// Credential is what the client presented. Exactly one concrete type per
114114+// scheme; a transport constructs the variant it can produce, or Anonymous.
115115+type Credential interface{ isCredential() }
116116+117117+// Anonymous is "no credential presented" (git://, or HTTP/SSH with none).
118118+type Anonymous struct{}
119119+120120+// PublicKey is an SSH public key. Uses x/crypto/ssh's type (gliderlabs/ssh
121121+// keys satisfy it) so this package stays free of the SSH server library.
122122+type PublicKey struct{ Key gossh.PublicKey }
123123+124124+// BasicAuth is an HTTP Basic credential. Unvalidated — the Authorizer owns the
125125+// user store.
126126+type BasicAuth struct{ Username, Password string }
127127+128128+// (BearerToken{Token string} can be added later without changing the interface.)
129129+130130+func (Anonymous) isCredential() {}
131131+func (PublicKey) isCredential() {}
132132+func (BasicAuth) isCredential() {}
133133+134134+// Request is a transport-neutral authorization request.
135135+type Request struct {
136136+ Repo string
137137+ Operation Operation
138138+ Cred Credential
139139+ Transport string // "git", "ssh", "http" — for policy/logging
140140+}
141141+142142+// Decision is the outcome. Unauthenticated is the seam that lets HTTP issue a
143143+// 401 challenge; SSH and git:// treat it as Deny.
144144+type Decision int
145145+146146+const (
147147+ Deny Decision = iota
148148+ Allow
149149+ Unauthenticated
150150+)
151151+152152+// Authorizer decides whether a request may proceed. This is the seam a real
153153+// authn/authz layer plugs into later.
154154+type Authorizer interface {
155155+ Authorize(ctx context.Context, req Request) Decision
156156+}
157157+158158+// AllowAnonymous is the permissive default: read for everyone, write only when
159159+// AllowWrite is set. "Dangerously allow everything the server is configured to
160160+// allow" — never more open than the -allow-push gate. It ignores the credential
161161+// entirely and never returns Unauthenticated.
162162+type AllowAnonymous struct{ AllowWrite bool }
163163+164164+func (a AllowAnonymous) Authorize(_ context.Context, req Request) Decision {
165165+ if req.Operation == Write && !a.AllowWrite {
166166+ return Deny
167167+ }
168168+ return Allow
169169+}
170170+```
171171+172172+`daemon` grows `authz auth.Authorizer` (added in `git_protocol.go`); `main.go`
173173+sets `authz: auth.AllowAnonymous{AllowWrite: *allowPush}`.
174174+175175+A small shared mapper (in `cmd/objgitd`, e.g. top of `git_protocol.go`) keeps the
176176+service→operation rule in one place:
177177+178178+```go
179179+func operationFor(service string) auth.Operation {
180180+ if service == transport.ReceivePackService {
181181+ return auth.Write
182182+ }
183183+ return auth.Read // upload-pack, upload-archive
184184+}
185185+```
186186+187187+## Retrofitting git:// and HTTP
188188+189189+### git:// — `handle` (git_protocol.go)
190190+191191+Replace the receive-pack-only `if !d.allowPush` check with an authz check that
192192+covers **all** services. git:// has no credential, so `Cred: auth.Anonymous{}`.
193193+194194+```go
195195+// before resolving the repo for any service:
196196+op := operationFor(req.RequestCommand)
197197+if d.authz.Authorize(ctx, auth.Request{
198198+ Repo: req.Pathname, Operation: op, Cred: auth.Anonymous{}, Transport: "git",
199199+}) != auth.Allow {
200200+ _, _ = pktline.WriteError(conn, fmt.Errorf("access denied"))
201201+ return fmt.Errorf("access denied for %q (%s)", req.Pathname, req.RequestCommand)
202202+}
203203+```
204204+205205+Then the existing per-service `Load` / `loadOrInit` + dispatch is unchanged.
206206+(`Unauthenticated` is impossible with `AllowAnonymous`; the `!= Allow` test
207207+treats it as denial regardless, which is correct for git://.)
208208+209209+### HTTP — `resolve` (http.go)
210210+211211+`resolve` currently takes `(w, service, repoPath)` and checks `d.allowPush`.
212212+Change it to also take the `*http.Request` so it can read the credential, and to
213213+render the decision in HTTP terms:
214214+215215+```go
216216+func (d *daemon) resolve(w http.ResponseWriter, r *http.Request, service, repoPath string) (storage.Storer, bool) {
217217+ cred := credFromRequest(r) // BasicAuth{} if Authorization: Basic present, else Anonymous{}
218218+ switch d.authz.Authorize(r.Context(), auth.Request{
219219+ Repo: repoPath, Operation: operationFor(service), Cred: cred, Transport: "http",
220220+ }) {
221221+ case auth.Allow:
222222+ // fall through
223223+ case auth.Unauthenticated:
224224+ w.Header().Set("WWW-Authenticate", `Basic realm="objgit"`)
225225+ http.Error(w, "authentication required", http.StatusUnauthorized)
226226+ return nil, false
227227+ default: // Deny
228228+ http.Error(w, "access denied", http.StatusForbidden)
229229+ return nil, false
230230+ }
231231+232232+ if service == transport.ReceivePackService {
233233+ st, err := d.loadOrInit(repoPath) // create-on-first-push, now gated by Allow above
234234+ if err != nil { /* 500, as today */ }
235235+ return st, true
236236+ }
237237+ st, err := d.loader.Load(&url.URL{Path: repoPath}) // 404 on ErrRepositoryNotFound, as today
238238+ ...
239239+}
240240+241241+func credFromRequest(r *http.Request) auth.Credential {
242242+ if u, p, ok := r.BasicAuth(); ok {
243243+ return auth.BasicAuth{Username: u, Password: p}
244244+ }
245245+ return auth.Anonymous{}
246246+}
247247+```
248248+249249+Update the two `resolve(w, service, repoPath)` call sites in `handleInfoRefs` and
250250+`handleRPC` to pass `r`. The `403`-on-disabled-push behavior is preserved
251251+(`AllowAnonymous{AllowWrite:false}` → `Deny` → `403`), and create-on-first-push
252252+still happens only after an `Allow`.
253253+254254+## ssh.go design
255255+256256+### Host key (persisted in the bucket)
257257+258258+`loadOrCreateHostKey(fs billy.Filesystem) (gossh.Signer, error)` reads
259259+`.objgit/ssh_host_ed25519_key` (PEM) through `d.fs`. If absent: generate ed25519,
260260+marshal to OpenSSH PEM (`gossh.MarshalPrivateKey` → `pem.EncodeToMemory`), write
261261+it back via the filesystem, log `"created ssh host key"`, parse into a signer
262262+(`gossh.ParsePrivateKey`). Use `billy.Filesystem` open/create — never local disk.
263263+264264+```go
265265+const hostKeyPath = ".objgit/ssh_host_ed25519_key"
266266+```
267267+268268+### Server construction
269269+270270+`newSSHServer(d *daemon, addr string) (*ssh.Server, error)`:
271271+272272+```go
273273+signer, err := loadOrCreateHostKey(d.fs)
274274+if err != nil {
275275+ return nil, fmt.Errorf("ssh host key: %w", err)
276276+}
277277+srv := &ssh.Server{
278278+ Addr: addr,
279279+ Handler: d.handleSSH, // func(ssh.Session)
280280+ PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
281281+ // Accept every key at connect; real authorization is per-command in
282282+ // handleSSH via d.authz. Stash the key for the auth.Request.
283283+ ctx.SetValue(pubKeyContextKey{}, key)
284284+ return true
285285+ },
286286+}
287287+srv.AddHostKey(signer)
288288+return srv, nil
289289+```
290290+291291+```go
292292+type pubKeyContextKey struct{}
293293+```
294294+295295+Setting `PublicKeyHandler` is what makes the server offer pubkey auth; returning
296296+`true` unconditionally is the "accept the connection" half — the `Authorizer` is
297297+the half that gates repo access.
298298+299299+### Command dispatch — `handleSSH(s ssh.Session)`
300300+301301+gliderlabs/ssh has already shlex-split the exec command, so `s.Command()` returns
302302+e.g. `["git-upload-pack", "/foo/bar.git"]` — no manual parsing.
303303+304304+1. **Reject interactive / malformed.** If `len(s.Command()) != 2`: friendly line
305305+ to `s.Stderr()` ("this is a git SSH endpoint; interactive shells are not
306306+ supported") + `s.Exit(1)`.
307307+2. **Map `cmd[0]` → service:**
308308+309309+ | `cmd[0]` | service |
310310+ | -------------------- | -------------------------------- |
311311+ | `git-upload-pack` | `transport.UploadPackService` |
312312+ | `git-upload-archive` | `transport.UploadArchiveService` |
313313+ | `git-receive-pack` | `transport.ReceivePackService` |
314314+315315+ Unknown → stderr error + `s.Exit(1)`.
316316+3. **Clean path:** `repoPath := strings.TrimPrefix(cmd[1], "/")` so
317317+ `ssh://host/foo.git` and scp-style `host:foo.git` resolve identically.
318318+4. **Authorize:**
319319+ ```go
320320+ key, _ := s.Context().Value(pubKeyContextKey{}).(ssh.PublicKey)
321321+ var cred auth.Credential = auth.Anonymous{}
322322+ if key != nil {
323323+ cred = auth.PublicKey{Key: key} // ssh.PublicKey satisfies gossh.PublicKey
324324+ }
325325+ if d.authz.Authorize(s.Context(), auth.Request{
326326+ Repo: repoPath, Operation: operationFor(service), Cred: cred, Transport: "ssh",
327327+ }) != auth.Allow {
328328+ fmt.Fprintln(s.Stderr(), "access denied")
329329+ _ = s.Exit(1)
330330+ return
331331+ }
332332+ ```
333333+5. **Resolve + dispatch.** Mirror `handle`; the session is reader and writer,
334334+ wrapped exactly as git:// does (see gotchas):
335335+336336+```go
337337+r := io.NopCloser(s)
338338+w := ioutil.WriteNopCloser(s)
339339+340340+switch service {
341341+case transport.UploadPackService:
342342+ st, err := d.loader.Load(&url.URL{Path: repoPath})
343343+ if err != nil {
344344+ fmt.Fprintf(s.Stderr(), "repository %q not found\n", repoPath)
345345+ _ = s.Exit(1)
346346+ return
347347+ }
348348+ if err := transport.UploadPack(s.Context(), st, r, w, &transport.UploadPackRequest{}); err != nil {
349349+ slog.Error("ssh upload-pack failed", "path", repoPath, "err", err)
350350+ }
351351+352352+case transport.UploadArchiveService:
353353+ st, err := d.loader.Load(&url.URL{Path: repoPath})
354354+ if err != nil {
355355+ fmt.Fprintf(s.Stderr(), "repository %q not found\n", repoPath)
356356+ _ = s.Exit(1)
357357+ return
358358+ }
359359+ if err := transport.UploadArchive(s.Context(), st, r, w, &transport.UploadArchiveRequest{}); err != nil {
360360+ slog.Error("ssh upload-archive failed", "path", repoPath, "err", err)
361361+ }
362362+363363+case transport.ReceivePackService:
364364+ st, err := d.loadOrInit(repoPath)
365365+ if err != nil {
366366+ fmt.Fprintf(s.Stderr(), "cannot open repository %q\n", repoPath)
367367+ _ = s.Exit(1)
368368+ return
369369+ }
370370+ if err := d.receivePack(s.Context(), streamingStorer{Storer: st}, st, repoPath, r, w, &transport.ReceivePackRequest{}); err != nil {
371371+ slog.Error("ssh receive-pack failed", "path", repoPath, "err", err)
372372+ }
373373+}
374374+```
375375+376376+`s.Context()` satisfies `context.Context`, threading cancellation into the
377377+transport calls. (Git protocol v2 over SSH arrives via the `GIT_PROTOCOL`
378378+env in an `setenv` request; gliderlabs/ssh gates env with a `LocalPortForwarding`-
379379+style allowlist — defaulting to v0/v1 negotiation is fine and matches what the
380380+git:// path does when `ExtraParams` is empty.)
381381+382382+### Two protocol gotchas (SSH = git://, NOT HTTP)
383383+384384+SSH is a persistent stream like git://, so **both** git:// workarounds apply —
385385+treating SSH like HTTP here is the failure mode:
386386+387387+1. **Hide `PackfileWriter` for receive-pack** — wrap the storer in the existing
388388+ `streamingStorer{}` (in `git_protocol.go`) so `UpdateObjectStorage` takes the
389389+ `Parser.Parse` path, not the `io.CopyBuffer`-until-`io.EOF` path that
390390+ deadlocks on a live socket waiting for report-status. Same loose-objects
391391+ trade-off as git:// (one S3 PUT per object).
392392+2. **No-op closers** — `transport.*` calls `Close` on the reader (and sometimes
393393+ the writer) between negotiation rounds; an `ssh.Session`'s real `Close` tears
394394+ down the channel. Use `io.NopCloser(s)` and `ioutil.WriteNopCloser(s)`
395395+ (`github.com/go-git/go-git/v6/utils/ioutil`).
396396+397397+Receive-pack dispatches through **`d.receivePack`** (the wrapper), not
398398+`transport.ReceivePack`, so push hooks fire over SSH exactly as over git:// / HTTP.
399399+400400+## main.go changes
401401+402402+- Add flag near the others:
403403+ ```go
404404+ sshBind = flag.String("ssh-bind", "", "TCP address to listen on for the git-over-SSH protocol; empty disables it")
405405+ ```
406406+- Relax the "at least one transport" guard to include `*sshBind`.
407407+- Build the daemon with the default authorizer and **no** `allowPush` field:
408408+ ```go
409409+ d := &daemon{
410410+ fs: fsys,
411411+ loader: transport.NewFilesystemLoader(fsys, false),
412412+ authz: auth.AllowAnonymous{AllowWrite: *allowPush},
413413+ allowHooks: *allowHooks,
414414+ hookTimeout: *hookTimeout,
415415+ }
416416+ ```
417417+- Add `"ssh_bind", *sshBind` to the `"objgitd listening"` slog line.
418418+- In the errgroup, when `*sshBind != ""`:
419419+ ```go
420420+ srv, err := newSSHServer(d, *sshBind)
421421+ if err != nil {
422422+ slog.Error("can't create ssh server", "ssh_bind", *sshBind, "err", err)
423423+ os.Exit(1)
424424+ }
425425+ g.Go(func() error {
426426+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
427427+ return err
428428+ }
429429+ return nil
430430+ })
431431+ g.Go(func() error {
432432+ <-gCtx.Done()
433433+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
434434+ defer cancel()
435435+ return srv.Shutdown(shutdownCtx)
436436+ })
437437+ ```
438438+ (`gliderlabs/ssh` exposes `ErrServerClosed`, `ListenAndServe`, `Shutdown`
439439+ mirroring `net/http`.)
440440+441441+## Tests
442442+443443+### internal/auth/auth_test.go (table-driven, no git needed)
444444+445445+`AllowAnonymous` decisions:
446446+447447+| AllowWrite | Operation | want |
448448+| ---------- | --------- | ------ |
449449+| false | Read | Allow |
450450+| false | Write | Deny |
451451+| true | Read | Allow |
452452+| true | Write | Allow |
453453+454454+One table, `tt`, asserting `(AllowAnonymous{tt.allowWrite}).Authorize(ctx, auth.Request{Operation: tt.op}) == tt.want`.
455455+456456+### Retrofit parity (existing suites)
457457+458458+The existing git:// and HTTP push-rejected-when-disabled tests must still pass
459459+unchanged — they now exercise the authz path. If `git_protocol_test.go` /
460460+`http_test.go` lack an explicit "anonymous read still works when push disabled"
461461+case, add one to each so the retrofit's read-path is covered.
462462+463463+### ssh_test.go (table-driven, gated on `exec.LookPath("git")`)
464464+465465+Reuse `seedRepo` / `runGit` / `tryGit` from `git_protocol_test.go`. Helper
466466+`startSSHServer(t, allowPush, allowHooks) (addr string, fs billy.Filesystem)`
467467+(`t.Helper()`): memfs-backed `daemon` with `authz: auth.AllowAnonymous{AllowWrite: allowPush}`,
468468+listen on `127.0.0.1:0`, serve `newSSHServer`'s server in a goroutine, return the
469469+resolved addr. Skip the subtest if `ssh`/`ssh-keygen` are missing.
470470+471471+Client identity via env on `runGit`:
472472+```go
473473+// ssh-keygen -t ed25519 -N "" -f <tmp>/id_ed25519
474474+env := append(os.Environ(),
475475+ "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
476476+ "GIT_TERMINAL_PROMPT=0",
477477+)
478478+```
479479+URL: `ssh://git@127.0.0.1:<port>/test.git`.
480480+481481+Cases (one table, `tt`, subtests):
482482+483483+- **push then clone round-trips** (`allowPush: true`): push a `seedRepo` commit,
484484+ assert `/test.git/config` in memfs, clone back, `git rev-parse HEAD` non-empty.
485485+- **push creates repo on demand** (`allowPush: true`): push to a not-yet-existing
486486+ path succeeds and creates the bare repo.
487487+- **push rejected when disabled** (`allowPush: false`): push fails;
488488+ `/test.git/config` never created (authz `Deny` for receive-pack).
489489+- **fetch from missing repo fails**: clone of a nonexistent path exits non-zero.
490490+- **hook fires on push** (`allowPush: true`, `allowHooks: true`): push a branch
491491+ carrying `.objgit/hooks/receive-pack`; assert the hook's effect using the
492492+ existing hook test's assertion pattern.
493493+494494+## Verification
495495+496496+1. `gofmt`/`goimports` clean; `go build ./...`.
497497+2. `go test ./...` — `internal/auth`, git://, HTTP, and SSH suites all pass.
498498+3. `go mod tidy` leaves `github.com/gliderlabs/ssh` (and `golang.org/x/crypto`)
499499+ resolved with no unexpected churn.
500500+4. Manual smoke against a real bucket:
501501+ ```
502502+ objgitd -bucket $BUCKET -ssh-bind :2222 -allow-push
503503+ GIT_SSH_COMMAND='ssh -p 2222 -o StrictHostKeyChecking=no' \
504504+ git clone ssh://git@localhost/smoke.git
505505+ (cd smoke && git push ssh://git@localhost:2222/smoke.git main) # creates + pushes
506506+ git clone ssh://git@localhost:2222/smoke.git verify # round-trips
507507+ ```
508508+ Confirm a second run reuses the persisted host key (no host-key-changed warning),
509509+ and that `-http-bind`/`-git-bind` still honor `-allow-push` through the shared authz.
510510+511511+## Build order
512512+513513+- [ ] **Task 1 — `internal/auth` package.** `auth.go` (interface, credentials,
514514+ `Decision`, `AllowAnonymous`) + `auth_test.go` table. `go test ./internal/auth/...`.
515515+- [ ] **Task 2 — `authz` on daemon + git:// retrofit.** Add `authz auth.Authorizer`
516516+ to `daemon`, remove `allowPush` field, add `operationFor`, route `handle`
517517+ through authz. Update `main.go` to construct `AllowAnonymous`. Existing git://
518518+ tests pass.
519519+- [ ] **Task 3 — HTTP retrofit.** `credFromRequest`, `resolve(w, r, service, repoPath)`
520520+ with 401/403/Allow handling; update the two call sites. Existing HTTP tests pass;
521521+ add the anonymous-read parity case.
522522+- [ ] **Task 4 — Host key.** `loadOrCreateHostKey` over `d.fs`; unit test on memfs
523523+ (first call creates+writes, second reads identical bytes).
524524+- [ ] **Task 5 — SSH server + dispatch.** `newSSHServer`, `pubKeyContextKey`,
525525+ `PublicKeyHandler`, `handleSSH` with the git://-style wrapping and authz check.
526526+- [ ] **Task 6 — main.go SSH wiring.** `-ssh-bind` flag, errgroup listener +
527527+ graceful shutdown, slog line. `go build -o objgitd ./cmd/objgitd`.
528528+- [ ] **Task 7 — SSH tests.** `ssh_test.go` table per the cases above; TDD each case.
529529+- [ ] **Task 8 — Docs.** Add the auth seam + SSH transport to `CLAUDE.md`.