Git backed by object storage because you can't stop me
git object-storage kefka
10

Configure Feed

Select the types of activity you want to include in your feed.

docs: add plan for git-over-SSH and generic auth seam

Xe Iaso (May 28, 2026, 10:52 PM EDT) 9f0d642f 9af0056c

+529
+529
docs/plans/ssh-protocol.md
··· 1 + # Plan: Git over SSH + a generic auth seam for objgitd 2 + 3 + > **For agentic workers:** implement task-by-task in build order (bottom of this 4 + > doc). Steps are checkboxes (`- [ ]`) for tracking. TDD where a real `git` 5 + > client can drive it; gate those tests on `exec.LookPath("git")` like the 6 + > existing suites. 7 + 8 + **Goal:** add a third Git transport — **SSH** (`ssh://host/repo.git`) — and, in 9 + the same change, introduce a transport-neutral **`internal/auth`** authorization 10 + seam that all three transports (git://, HTTP, SSH) funnel through, replacing the 11 + scattered `-allow-push` checks with one pluggable `Authorizer`. 12 + 13 + ## Context 14 + 15 + `objgitd` serves repositories out of a Tigris/S3-backed billy filesystem over 16 + **git:// (TCP)** (`cmd/objgitd/git_protocol.go`) and **smart-HTTP** 17 + (`cmd/objgitd/http.go`). Both wrap one `*daemon` (`fs`, `loader`, `loadOrInit`, 18 + `receivePack`) and answer the protocol **natively** with go-git's 19 + `transport.UploadPack` / `ReceivePack` / `UploadArchive` — there is **no `git` 20 + binary and no on-disk checkout**. 21 + 22 + This is the key difference from charmbracelet/wish's `git/git.go` (the reference 23 + the user started from): wish `exec`s the real `git-upload-pack` binary against 24 + an on-disk repo. We do **not** do that. The SSH handler is a third sibling to 25 + `git_protocol.go` / `http.go`: read the requested command, resolve the repo, 26 + hand the SSH session's stdin/stdout to the same `transport.*` functions. From 27 + wish we keep only the SSH-side mechanics (command → service mapping, path 28 + cleaning, session wiring). 29 + 30 + SSH is, like git://, a **persistent bidirectional stream** (not HTTP's 31 + request/response), so the two git:// protocol workarounds apply verbatim and the 32 + HTTP path's assumptions do **not** — see [Two protocol gotchas](#two-protocol-gotchas-ssh--git-not-http). 33 + 34 + ### Why a generic auth seam 35 + 36 + Each transport carries a different credential, and HTTP needs a challenge flow 37 + the others don't: 38 + 39 + | Transport | Credential it presents | "please authenticate" mechanism | 40 + | --------- | ----------------------------- | ------------------------------------- | 41 + | git:// | none (anonymous) | n/a | 42 + | SSH | public key (validated at connect) | handled in the SSH auth handshake | 43 + | HTTP | `Authorization: Basic …` / none | `401` + `WWW-Authenticate` | 44 + 45 + The transport must **not** validate credentials itself (a password is only 46 + checkable against a user store the *policy* owns). Each transport's job shrinks 47 + to three things: **collect** the raw credential, **map** the git service to a 48 + read/write `Operation`, and **enforce** the returned `Decision` in its own 49 + dialect. All real auth logic lives behind one interface, so the same mechanism 50 + serves HTTP basic auth, SSH keys, and any future scheme (bearer tokens, mTLS). 51 + 52 + Decisions from the user: 53 + 54 + - **One generic `Authorizer` interface**, credential-agnostic on input, with a 55 + `Decision` expressive enough that HTTP can render a 401 challenge while 56 + SSH/git:// just allow-or-deny. 57 + - **Retrofit all three transports now**, replacing the three scattered 58 + `-allow-push` checks with a single default authorizer 59 + `auth.AllowAnonymous{AllowWrite: *allowPush}`. 60 + - **Stub permissive.** The only implementation today is `AllowAnonymous`; a real 61 + authn/authz layer plugs in later without touching transport code. 62 + - **Host key persisted in the bucket** (`.objgit/ssh_host_ed25519_key`), so SSH 63 + needs no operator config and survives restarts without host-key-changed warnings. 64 + - **Opt-in SSH.** New `-ssh-bind` flag defaults to `""` (disabled), matching git://. 65 + 66 + Code/test conventions follow `xe-go:xe-go-style` and `xe-go:go-table-driven-tests` 67 + (flagenv→flag, kebab-case flags, slog with `"err"` key, errgroup server 68 + lifecycle, `t.Helper()` helpers, table-driven subtests with `tt`). 69 + 70 + ## Files 71 + 72 + | Action | Path | Purpose | 73 + | ------ | ----------------------------- | ------------------------------------------------------ | 74 + | Create | `internal/auth/auth.go` | transport-neutral `Authorizer`, `Credential`, `Decision`, `AllowAnonymous` | 75 + | Create | `internal/auth/auth_test.go` | unit tests for `AllowAnonymous` decisions | 76 + | Create | `cmd/objgitd/ssh.go` | SSH server, host key, command dispatch | 77 + | Create | `cmd/objgitd/ssh_test.go` | table-driven clone/push/deny/create/hook tests | 78 + | Edit | `cmd/objgitd/git_protocol.go` | `authz` field on `daemon`; git:// goes through authz | 79 + | Edit | `cmd/objgitd/http.go` | HTTP goes through authz (Basic-auth parse, 401/403) | 80 + | Edit | `cmd/objgitd/main.go` | `-ssh-bind` flag, default authz, SSH errgroup listener | 81 + | Edit | `go.mod` / `go.sum` | add `github.com/gliderlabs/ssh` (direct) | 82 + | Edit | `CLAUDE.md` | document the auth seam + third transport | 83 + 84 + The `daemon` struct stays the shared backend. The `allowPush bool` field is 85 + **removed** from `daemon` (the flag survives in `main.go` only, to build the 86 + default authorizer); SSH methods live in `ssh.go`. 87 + 88 + ## internal/auth package 89 + 90 + Transport-neutral on purpose: it imports only `context` and 91 + `golang.org/x/crypto/ssh` (for the public-key wire type), **not** gliderlabs/ssh 92 + or go-git. Transports translate their own world into `auth.Request` and act on 93 + `auth.Decision`. 94 + 95 + ```go 96 + package auth 97 + 98 + import ( 99 + "context" 100 + 101 + gossh "golang.org/x/crypto/ssh" 102 + ) 103 + 104 + // Operation is the access a request needs. Transports map the git service: 105 + // upload-pack/upload-archive → Read, receive-pack → Write. 106 + type Operation int 107 + 108 + const ( 109 + Read Operation = iota 110 + Write 111 + ) 112 + 113 + // Credential is what the client presented. Exactly one concrete type per 114 + // scheme; a transport constructs the variant it can produce, or Anonymous. 115 + type Credential interface{ isCredential() } 116 + 117 + // Anonymous is "no credential presented" (git://, or HTTP/SSH with none). 118 + type Anonymous struct{} 119 + 120 + // PublicKey is an SSH public key. Uses x/crypto/ssh's type (gliderlabs/ssh 121 + // keys satisfy it) so this package stays free of the SSH server library. 122 + type PublicKey struct{ Key gossh.PublicKey } 123 + 124 + // BasicAuth is an HTTP Basic credential. Unvalidated — the Authorizer owns the 125 + // user store. 126 + type BasicAuth struct{ Username, Password string } 127 + 128 + // (BearerToken{Token string} can be added later without changing the interface.) 129 + 130 + func (Anonymous) isCredential() {} 131 + func (PublicKey) isCredential() {} 132 + func (BasicAuth) isCredential() {} 133 + 134 + // Request is a transport-neutral authorization request. 135 + type Request struct { 136 + Repo string 137 + Operation Operation 138 + Cred Credential 139 + Transport string // "git", "ssh", "http" — for policy/logging 140 + } 141 + 142 + // Decision is the outcome. Unauthenticated is the seam that lets HTTP issue a 143 + // 401 challenge; SSH and git:// treat it as Deny. 144 + type Decision int 145 + 146 + const ( 147 + Deny Decision = iota 148 + Allow 149 + Unauthenticated 150 + ) 151 + 152 + // Authorizer decides whether a request may proceed. This is the seam a real 153 + // authn/authz layer plugs into later. 154 + type Authorizer interface { 155 + Authorize(ctx context.Context, req Request) Decision 156 + } 157 + 158 + // AllowAnonymous is the permissive default: read for everyone, write only when 159 + // AllowWrite is set. "Dangerously allow everything the server is configured to 160 + // allow" — never more open than the -allow-push gate. It ignores the credential 161 + // entirely and never returns Unauthenticated. 162 + type AllowAnonymous struct{ AllowWrite bool } 163 + 164 + func (a AllowAnonymous) Authorize(_ context.Context, req Request) Decision { 165 + if req.Operation == Write && !a.AllowWrite { 166 + return Deny 167 + } 168 + return Allow 169 + } 170 + ``` 171 + 172 + `daemon` grows `authz auth.Authorizer` (added in `git_protocol.go`); `main.go` 173 + sets `authz: auth.AllowAnonymous{AllowWrite: *allowPush}`. 174 + 175 + A small shared mapper (in `cmd/objgitd`, e.g. top of `git_protocol.go`) keeps the 176 + service→operation rule in one place: 177 + 178 + ```go 179 + func operationFor(service string) auth.Operation { 180 + if service == transport.ReceivePackService { 181 + return auth.Write 182 + } 183 + return auth.Read // upload-pack, upload-archive 184 + } 185 + ``` 186 + 187 + ## Retrofitting git:// and HTTP 188 + 189 + ### git:// — `handle` (git_protocol.go) 190 + 191 + Replace the receive-pack-only `if !d.allowPush` check with an authz check that 192 + covers **all** services. git:// has no credential, so `Cred: auth.Anonymous{}`. 193 + 194 + ```go 195 + // before resolving the repo for any service: 196 + op := operationFor(req.RequestCommand) 197 + if d.authz.Authorize(ctx, auth.Request{ 198 + Repo: req.Pathname, Operation: op, Cred: auth.Anonymous{}, Transport: "git", 199 + }) != auth.Allow { 200 + _, _ = pktline.WriteError(conn, fmt.Errorf("access denied")) 201 + return fmt.Errorf("access denied for %q (%s)", req.Pathname, req.RequestCommand) 202 + } 203 + ``` 204 + 205 + Then the existing per-service `Load` / `loadOrInit` + dispatch is unchanged. 206 + (`Unauthenticated` is impossible with `AllowAnonymous`; the `!= Allow` test 207 + treats it as denial regardless, which is correct for git://.) 208 + 209 + ### HTTP — `resolve` (http.go) 210 + 211 + `resolve` currently takes `(w, service, repoPath)` and checks `d.allowPush`. 212 + Change it to also take the `*http.Request` so it can read the credential, and to 213 + render the decision in HTTP terms: 214 + 215 + ```go 216 + func (d *daemon) resolve(w http.ResponseWriter, r *http.Request, service, repoPath string) (storage.Storer, bool) { 217 + cred := credFromRequest(r) // BasicAuth{} if Authorization: Basic present, else Anonymous{} 218 + switch d.authz.Authorize(r.Context(), auth.Request{ 219 + Repo: repoPath, Operation: operationFor(service), Cred: cred, Transport: "http", 220 + }) { 221 + case auth.Allow: 222 + // fall through 223 + case auth.Unauthenticated: 224 + w.Header().Set("WWW-Authenticate", `Basic realm="objgit"`) 225 + http.Error(w, "authentication required", http.StatusUnauthorized) 226 + return nil, false 227 + default: // Deny 228 + http.Error(w, "access denied", http.StatusForbidden) 229 + return nil, false 230 + } 231 + 232 + if service == transport.ReceivePackService { 233 + st, err := d.loadOrInit(repoPath) // create-on-first-push, now gated by Allow above 234 + if err != nil { /* 500, as today */ } 235 + return st, true 236 + } 237 + st, err := d.loader.Load(&url.URL{Path: repoPath}) // 404 on ErrRepositoryNotFound, as today 238 + ... 239 + } 240 + 241 + func credFromRequest(r *http.Request) auth.Credential { 242 + if u, p, ok := r.BasicAuth(); ok { 243 + return auth.BasicAuth{Username: u, Password: p} 244 + } 245 + return auth.Anonymous{} 246 + } 247 + ``` 248 + 249 + Update the two `resolve(w, service, repoPath)` call sites in `handleInfoRefs` and 250 + `handleRPC` to pass `r`. The `403`-on-disabled-push behavior is preserved 251 + (`AllowAnonymous{AllowWrite:false}` → `Deny` → `403`), and create-on-first-push 252 + still happens only after an `Allow`. 253 + 254 + ## ssh.go design 255 + 256 + ### Host key (persisted in the bucket) 257 + 258 + `loadOrCreateHostKey(fs billy.Filesystem) (gossh.Signer, error)` reads 259 + `.objgit/ssh_host_ed25519_key` (PEM) through `d.fs`. If absent: generate ed25519, 260 + marshal to OpenSSH PEM (`gossh.MarshalPrivateKey` → `pem.EncodeToMemory`), write 261 + it back via the filesystem, log `"created ssh host key"`, parse into a signer 262 + (`gossh.ParsePrivateKey`). Use `billy.Filesystem` open/create — never local disk. 263 + 264 + ```go 265 + const hostKeyPath = ".objgit/ssh_host_ed25519_key" 266 + ``` 267 + 268 + ### Server construction 269 + 270 + `newSSHServer(d *daemon, addr string) (*ssh.Server, error)`: 271 + 272 + ```go 273 + signer, err := loadOrCreateHostKey(d.fs) 274 + if err != nil { 275 + return nil, fmt.Errorf("ssh host key: %w", err) 276 + } 277 + srv := &ssh.Server{ 278 + Addr: addr, 279 + Handler: d.handleSSH, // func(ssh.Session) 280 + PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { 281 + // Accept every key at connect; real authorization is per-command in 282 + // handleSSH via d.authz. Stash the key for the auth.Request. 283 + ctx.SetValue(pubKeyContextKey{}, key) 284 + return true 285 + }, 286 + } 287 + srv.AddHostKey(signer) 288 + return srv, nil 289 + ``` 290 + 291 + ```go 292 + type pubKeyContextKey struct{} 293 + ``` 294 + 295 + Setting `PublicKeyHandler` is what makes the server offer pubkey auth; returning 296 + `true` unconditionally is the "accept the connection" half — the `Authorizer` is 297 + the half that gates repo access. 298 + 299 + ### Command dispatch — `handleSSH(s ssh.Session)` 300 + 301 + gliderlabs/ssh has already shlex-split the exec command, so `s.Command()` returns 302 + e.g. `["git-upload-pack", "/foo/bar.git"]` — no manual parsing. 303 + 304 + 1. **Reject interactive / malformed.** If `len(s.Command()) != 2`: friendly line 305 + to `s.Stderr()` ("this is a git SSH endpoint; interactive shells are not 306 + supported") + `s.Exit(1)`. 307 + 2. **Map `cmd[0]` → service:** 308 + 309 + | `cmd[0]` | service | 310 + | -------------------- | -------------------------------- | 311 + | `git-upload-pack` | `transport.UploadPackService` | 312 + | `git-upload-archive` | `transport.UploadArchiveService` | 313 + | `git-receive-pack` | `transport.ReceivePackService` | 314 + 315 + Unknown → stderr error + `s.Exit(1)`. 316 + 3. **Clean path:** `repoPath := strings.TrimPrefix(cmd[1], "/")` so 317 + `ssh://host/foo.git` and scp-style `host:foo.git` resolve identically. 318 + 4. **Authorize:** 319 + ```go 320 + key, _ := s.Context().Value(pubKeyContextKey{}).(ssh.PublicKey) 321 + var cred auth.Credential = auth.Anonymous{} 322 + if key != nil { 323 + cred = auth.PublicKey{Key: key} // ssh.PublicKey satisfies gossh.PublicKey 324 + } 325 + if d.authz.Authorize(s.Context(), auth.Request{ 326 + Repo: repoPath, Operation: operationFor(service), Cred: cred, Transport: "ssh", 327 + }) != auth.Allow { 328 + fmt.Fprintln(s.Stderr(), "access denied") 329 + _ = s.Exit(1) 330 + return 331 + } 332 + ``` 333 + 5. **Resolve + dispatch.** Mirror `handle`; the session is reader and writer, 334 + wrapped exactly as git:// does (see gotchas): 335 + 336 + ```go 337 + r := io.NopCloser(s) 338 + w := ioutil.WriteNopCloser(s) 339 + 340 + switch service { 341 + case transport.UploadPackService: 342 + st, err := d.loader.Load(&url.URL{Path: repoPath}) 343 + if err != nil { 344 + fmt.Fprintf(s.Stderr(), "repository %q not found\n", repoPath) 345 + _ = s.Exit(1) 346 + return 347 + } 348 + if err := transport.UploadPack(s.Context(), st, r, w, &transport.UploadPackRequest{}); err != nil { 349 + slog.Error("ssh upload-pack failed", "path", repoPath, "err", err) 350 + } 351 + 352 + case transport.UploadArchiveService: 353 + st, err := d.loader.Load(&url.URL{Path: repoPath}) 354 + if err != nil { 355 + fmt.Fprintf(s.Stderr(), "repository %q not found\n", repoPath) 356 + _ = s.Exit(1) 357 + return 358 + } 359 + if err := transport.UploadArchive(s.Context(), st, r, w, &transport.UploadArchiveRequest{}); err != nil { 360 + slog.Error("ssh upload-archive failed", "path", repoPath, "err", err) 361 + } 362 + 363 + case transport.ReceivePackService: 364 + st, err := d.loadOrInit(repoPath) 365 + if err != nil { 366 + fmt.Fprintf(s.Stderr(), "cannot open repository %q\n", repoPath) 367 + _ = s.Exit(1) 368 + return 369 + } 370 + if err := d.receivePack(s.Context(), streamingStorer{Storer: st}, st, repoPath, r, w, &transport.ReceivePackRequest{}); err != nil { 371 + slog.Error("ssh receive-pack failed", "path", repoPath, "err", err) 372 + } 373 + } 374 + ``` 375 + 376 + `s.Context()` satisfies `context.Context`, threading cancellation into the 377 + transport calls. (Git protocol v2 over SSH arrives via the `GIT_PROTOCOL` 378 + env in an `setenv` request; gliderlabs/ssh gates env with a `LocalPortForwarding`- 379 + style allowlist — defaulting to v0/v1 negotiation is fine and matches what the 380 + git:// path does when `ExtraParams` is empty.) 381 + 382 + ### Two protocol gotchas (SSH = git://, NOT HTTP) 383 + 384 + SSH is a persistent stream like git://, so **both** git:// workarounds apply — 385 + treating SSH like HTTP here is the failure mode: 386 + 387 + 1. **Hide `PackfileWriter` for receive-pack** — wrap the storer in the existing 388 + `streamingStorer{}` (in `git_protocol.go`) so `UpdateObjectStorage` takes the 389 + `Parser.Parse` path, not the `io.CopyBuffer`-until-`io.EOF` path that 390 + deadlocks on a live socket waiting for report-status. Same loose-objects 391 + trade-off as git:// (one S3 PUT per object). 392 + 2. **No-op closers** — `transport.*` calls `Close` on the reader (and sometimes 393 + the writer) between negotiation rounds; an `ssh.Session`'s real `Close` tears 394 + down the channel. Use `io.NopCloser(s)` and `ioutil.WriteNopCloser(s)` 395 + (`github.com/go-git/go-git/v6/utils/ioutil`). 396 + 397 + Receive-pack dispatches through **`d.receivePack`** (the wrapper), not 398 + `transport.ReceivePack`, so push hooks fire over SSH exactly as over git:// / HTTP. 399 + 400 + ## main.go changes 401 + 402 + - Add flag near the others: 403 + ```go 404 + sshBind = flag.String("ssh-bind", "", "TCP address to listen on for the git-over-SSH protocol; empty disables it") 405 + ``` 406 + - Relax the "at least one transport" guard to include `*sshBind`. 407 + - Build the daemon with the default authorizer and **no** `allowPush` field: 408 + ```go 409 + d := &daemon{ 410 + fs: fsys, 411 + loader: transport.NewFilesystemLoader(fsys, false), 412 + authz: auth.AllowAnonymous{AllowWrite: *allowPush}, 413 + allowHooks: *allowHooks, 414 + hookTimeout: *hookTimeout, 415 + } 416 + ``` 417 + - Add `"ssh_bind", *sshBind` to the `"objgitd listening"` slog line. 418 + - In the errgroup, when `*sshBind != ""`: 419 + ```go 420 + srv, err := newSSHServer(d, *sshBind) 421 + if err != nil { 422 + slog.Error("can't create ssh server", "ssh_bind", *sshBind, "err", err) 423 + os.Exit(1) 424 + } 425 + g.Go(func() error { 426 + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { 427 + return err 428 + } 429 + return nil 430 + }) 431 + g.Go(func() error { 432 + <-gCtx.Done() 433 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 434 + defer cancel() 435 + return srv.Shutdown(shutdownCtx) 436 + }) 437 + ``` 438 + (`gliderlabs/ssh` exposes `ErrServerClosed`, `ListenAndServe`, `Shutdown` 439 + mirroring `net/http`.) 440 + 441 + ## Tests 442 + 443 + ### internal/auth/auth_test.go (table-driven, no git needed) 444 + 445 + `AllowAnonymous` decisions: 446 + 447 + | AllowWrite | Operation | want | 448 + | ---------- | --------- | ------ | 449 + | false | Read | Allow | 450 + | false | Write | Deny | 451 + | true | Read | Allow | 452 + | true | Write | Allow | 453 + 454 + One table, `tt`, asserting `(AllowAnonymous{tt.allowWrite}).Authorize(ctx, auth.Request{Operation: tt.op}) == tt.want`. 455 + 456 + ### Retrofit parity (existing suites) 457 + 458 + The existing git:// and HTTP push-rejected-when-disabled tests must still pass 459 + unchanged — they now exercise the authz path. If `git_protocol_test.go` / 460 + `http_test.go` lack an explicit "anonymous read still works when push disabled" 461 + case, add one to each so the retrofit's read-path is covered. 462 + 463 + ### ssh_test.go (table-driven, gated on `exec.LookPath("git")`) 464 + 465 + Reuse `seedRepo` / `runGit` / `tryGit` from `git_protocol_test.go`. Helper 466 + `startSSHServer(t, allowPush, allowHooks) (addr string, fs billy.Filesystem)` 467 + (`t.Helper()`): memfs-backed `daemon` with `authz: auth.AllowAnonymous{AllowWrite: allowPush}`, 468 + listen on `127.0.0.1:0`, serve `newSSHServer`'s server in a goroutine, return the 469 + resolved addr. Skip the subtest if `ssh`/`ssh-keygen` are missing. 470 + 471 + Client identity via env on `runGit`: 472 + ```go 473 + // ssh-keygen -t ed25519 -N "" -f <tmp>/id_ed25519 474 + env := append(os.Environ(), 475 + "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", 476 + "GIT_TERMINAL_PROMPT=0", 477 + ) 478 + ``` 479 + URL: `ssh://git@127.0.0.1:<port>/test.git`. 480 + 481 + Cases (one table, `tt`, subtests): 482 + 483 + - **push then clone round-trips** (`allowPush: true`): push a `seedRepo` commit, 484 + assert `/test.git/config` in memfs, clone back, `git rev-parse HEAD` non-empty. 485 + - **push creates repo on demand** (`allowPush: true`): push to a not-yet-existing 486 + path succeeds and creates the bare repo. 487 + - **push rejected when disabled** (`allowPush: false`): push fails; 488 + `/test.git/config` never created (authz `Deny` for receive-pack). 489 + - **fetch from missing repo fails**: clone of a nonexistent path exits non-zero. 490 + - **hook fires on push** (`allowPush: true`, `allowHooks: true`): push a branch 491 + carrying `.objgit/hooks/receive-pack`; assert the hook's effect using the 492 + existing hook test's assertion pattern. 493 + 494 + ## Verification 495 + 496 + 1. `gofmt`/`goimports` clean; `go build ./...`. 497 + 2. `go test ./...` — `internal/auth`, git://, HTTP, and SSH suites all pass. 498 + 3. `go mod tidy` leaves `github.com/gliderlabs/ssh` (and `golang.org/x/crypto`) 499 + resolved with no unexpected churn. 500 + 4. Manual smoke against a real bucket: 501 + ``` 502 + objgitd -bucket $BUCKET -ssh-bind :2222 -allow-push 503 + GIT_SSH_COMMAND='ssh -p 2222 -o StrictHostKeyChecking=no' \ 504 + git clone ssh://git@localhost/smoke.git 505 + (cd smoke && git push ssh://git@localhost:2222/smoke.git main) # creates + pushes 506 + git clone ssh://git@localhost:2222/smoke.git verify # round-trips 507 + ``` 508 + Confirm a second run reuses the persisted host key (no host-key-changed warning), 509 + and that `-http-bind`/`-git-bind` still honor `-allow-push` through the shared authz. 510 + 511 + ## Build order 512 + 513 + - [ ] **Task 1 — `internal/auth` package.** `auth.go` (interface, credentials, 514 + `Decision`, `AllowAnonymous`) + `auth_test.go` table. `go test ./internal/auth/...`. 515 + - [ ] **Task 2 — `authz` on daemon + git:// retrofit.** Add `authz auth.Authorizer` 516 + to `daemon`, remove `allowPush` field, add `operationFor`, route `handle` 517 + through authz. Update `main.go` to construct `AllowAnonymous`. Existing git:// 518 + tests pass. 519 + - [ ] **Task 3 — HTTP retrofit.** `credFromRequest`, `resolve(w, r, service, repoPath)` 520 + with 401/403/Allow handling; update the two call sites. Existing HTTP tests pass; 521 + add the anonymous-read parity case. 522 + - [ ] **Task 4 — Host key.** `loadOrCreateHostKey` over `d.fs`; unit test on memfs 523 + (first call creates+writes, second reads identical bytes). 524 + - [ ] **Task 5 — SSH server + dispatch.** `newSSHServer`, `pubKeyContextKey`, 525 + `PublicKeyHandler`, `handleSSH` with the git://-style wrapping and authz check. 526 + - [ ] **Task 6 — main.go SSH wiring.** `-ssh-bind` flag, errgroup listener + 527 + graceful shutdown, slog line. `go build -o objgitd ./cmd/objgitd`. 528 + - [ ] **Task 7 — SSH tests.** `ssh_test.go` table per the cases above; TDD each case. 529 + - [ ] **Task 8 — Docs.** Add the auth seam + SSH transport to `CLAUDE.md`.