For agentic workers: implement task-by-task in build order (bottom of this doc). Steps are checkboxes (
- [ ]) for tracking. TDD where a realgitclient can drive it; gate those tests onexec.LookPath("git")like the existing suites.
Goal: add a third Git transport — SSH (ssh://host/repo.git) — and, in
the same change, introduce a transport-neutral internal/auth authorization
seam that all three transports (git://, HTTP, SSH) funnel through, replacing the
scattered -allow-push checks with one pluggable Authorizer.
objgitd serves repositories out of a Tigris/S3-backed billy filesystem over
git:// (TCP) (cmd/objgitd/git_protocol.go) and smart-HTTP
(cmd/objgitd/http.go). Both wrap one *daemon (fs, loader, loadOrInit,
receivePack) and answer the protocol natively with go-git's
transport.UploadPack / ReceivePack / UploadArchive — there is no git
binary and no on-disk checkout.
This is the key difference from charmbracelet/wish's git/git.go (the reference
the user started from): wish execs the real git-upload-pack binary against
an on-disk repo. We do not do that. The SSH handler is a third sibling to
git_protocol.go / http.go: read the requested command, resolve the repo,
hand the SSH session's stdin/stdout to the same transport.* functions. From
wish we keep only the SSH-side mechanics (command → service mapping, path
cleaning, session wiring).
SSH is, like git://, a persistent bidirectional stream (not HTTP's request/response), so the two git:// protocol workarounds apply verbatim and the HTTP path's assumptions do not — see Two protocol gotchas.
Each transport carries a different credential, and HTTP needs a challenge flow the others don't:
| Transport | Credential it presents | "please authenticate" mechanism |
|---|---|---|
| git:// | none (anonymous) | n/a |
| SSH | public key (validated at connect) | handled in the SSH auth handshake |
| HTTP | Authorization: Basic … / none |
401 + WWW-Authenticate |
The transport must not validate credentials itself (a password is only
checkable against a user store the policy owns). Each transport's job shrinks
to three things: collect the raw credential, map the git service to a
read/write Operation, and enforce the returned Decision in its own
dialect. All real auth logic lives behind one interface, so the same mechanism
serves HTTP basic auth, SSH keys, and any future scheme (bearer tokens, mTLS).
Decisions from the user:
- One generic
Authorizerinterface, credential-agnostic on input, with aDecisionexpressive enough that HTTP can render a 401 challenge while SSH/git:// just allow-or-deny. - Retrofit all three transports now, replacing the three scattered
-allow-pushchecks with a single default authorizerauth.AllowAnonymous{AllowWrite: *allowPush}. - Stub permissive. The only implementation today is
AllowAnonymous; a real authn/authz layer plugs in later without touching transport code. - Host key persisted in the bucket (
.objgit/ssh_host_ed25519_key), so SSH needs no operator config and survives restarts without host-key-changed warnings. - Opt-in SSH. New
-ssh-bindflag defaults to""(disabled), matching git://.
Code/test conventions follow xe-go:xe-go-style and xe-go:go-table-driven-tests
(flagenv→flag, kebab-case flags, slog with "err" key, errgroup server
lifecycle, t.Helper() helpers, table-driven subtests with tt).
| Action | Path | Purpose |
|---|---|---|
| Create | internal/auth/auth.go |
transport-neutral Authorizer, Credential, Decision, AllowAnonymous |
| Create | internal/auth/auth_test.go |
unit tests for AllowAnonymous decisions |
| Create | cmd/objgitd/ssh.go |
SSH server, host key, command dispatch |
| Create | cmd/objgitd/ssh_test.go |
table-driven clone/push/deny/create/hook tests |
| Edit | cmd/objgitd/git_protocol.go |
authz field on daemon; git:// goes through authz |
| Edit | cmd/objgitd/http.go |
HTTP goes through authz (Basic-auth parse, 401/403) |
| Edit | cmd/objgitd/main.go |
-ssh-bind flag, default authz, SSH errgroup listener |
| Edit | go.mod / go.sum |
add github.com/gliderlabs/ssh (direct) |
| Edit | CLAUDE.md |
document the auth seam + third transport |
The daemon struct stays the shared backend. The allowPush bool field is
removed from daemon (the flag survives in main.go only, to build the
default authorizer); SSH methods live in ssh.go.
Transport-neutral on purpose: it imports only context and
golang.org/x/crypto/ssh (for the public-key wire type), not gliderlabs/ssh
or go-git. Transports translate their own world into auth.Request and act on
auth.Decision.
package auth
import (
"context"
gossh "golang.org/x/crypto/ssh"
)
// Operation is the access a request needs. Transports map the git service:
// upload-pack/upload-archive → Read, receive-pack → Write.
type Operation int
const (
Read Operation = iota
Write
)
// Credential is what the client presented. Exactly one concrete type per
// scheme; a transport constructs the variant it can produce, or Anonymous.
type Credential interface{ isCredential() }
// Anonymous is "no credential presented" (git://, or HTTP/SSH with none).
type Anonymous struct{}
// PublicKey is an SSH public key. Uses x/crypto/ssh's type (gliderlabs/ssh
// keys satisfy it) so this package stays free of the SSH server library.
type PublicKey struct{ Key gossh.PublicKey }
// BasicAuth is an HTTP Basic credential. Unvalidated — the Authorizer owns the
// user store.
type BasicAuth struct{ Username, Password string }
// (BearerToken{Token string} can be added later without changing the interface.)
func (Anonymous) isCredential() {}
func (PublicKey) isCredential() {}
func (BasicAuth) isCredential() {}
// Request is a transport-neutral authorization request.
type Request struct {
Repo string
Operation Operation
Cred Credential
Transport string // "git", "ssh", "http" — for policy/logging
}
// Decision is the outcome. Unauthenticated is the seam that lets HTTP issue a
// 401 challenge; SSH and git:// treat it as Deny.
type Decision int
const (
Deny Decision = iota
Allow
Unauthenticated
)
// Authorizer decides whether a request may proceed. This is the seam a real
// authn/authz layer plugs into later.
type Authorizer interface {
Authorize(ctx context.Context, req Request) Decision
}
// AllowAnonymous is the permissive default: read for everyone, write only when
// AllowWrite is set. "Dangerously allow everything the server is configured to
// allow" — never more open than the -allow-push gate. It ignores the credential
// entirely and never returns Unauthenticated.
type AllowAnonymous struct{ AllowWrite bool }
func (a AllowAnonymous) Authorize(_ context.Context, req Request) Decision {
if req.Operation == Write && !a.AllowWrite {
return Deny
}
return Allow
}daemon grows authz auth.Authorizer (added in git_protocol.go); main.go
sets authz: auth.AllowAnonymous{AllowWrite: *allowPush}.
A small shared mapper (in cmd/objgitd, e.g. top of git_protocol.go) keeps the
service→operation rule in one place:
func operationFor(service string) auth.Operation {
if service == transport.ReceivePackService {
return auth.Write
}
return auth.Read // upload-pack, upload-archive
}Replace the receive-pack-only if !d.allowPush check with an authz check that
covers all services. git:// has no credential, so Cred: auth.Anonymous{}.
// before resolving the repo for any service:
op := operationFor(req.RequestCommand)
if d.authz.Authorize(ctx, auth.Request{
Repo: req.Pathname, Operation: op, Cred: auth.Anonymous{}, Transport: "git",
}) != auth.Allow {
_, _ = pktline.WriteError(conn, fmt.Errorf("access denied"))
return fmt.Errorf("access denied for %q (%s)", req.Pathname, req.RequestCommand)
}Then the existing per-service Load / loadOrInit + dispatch is unchanged.
(Unauthenticated is impossible with AllowAnonymous; the != Allow test
treats it as denial regardless, which is correct for git://.)
resolve currently takes (w, service, repoPath) and checks d.allowPush.
Change it to also take the *http.Request so it can read the credential, and to
render the decision in HTTP terms:
func (d *daemon) resolve(w http.ResponseWriter, r *http.Request, service, repoPath string) (storage.Storer, bool) {
cred := credFromRequest(r) // BasicAuth{} if Authorization: Basic present, else Anonymous{}
switch d.authz.Authorize(r.Context(), auth.Request{
Repo: repoPath, Operation: operationFor(service), Cred: cred, Transport: "http",
}) {
case auth.Allow:
// fall through
case auth.Unauthenticated:
w.Header().Set("WWW-Authenticate", `Basic realm="objgit"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return nil, false
default: // Deny
http.Error(w, "access denied", http.StatusForbidden)
return nil, false
}
if service == transport.ReceivePackService {
st, err := d.loadOrInit(repoPath) // create-on-first-push, now gated by Allow above
if err != nil { /* 500, as today */ }
return st, true
}
st, err := d.loader.Load(&url.URL{Path: repoPath}) // 404 on ErrRepositoryNotFound, as today
...
}
func credFromRequest(r *http.Request) auth.Credential {
if u, p, ok := r.BasicAuth(); ok {
return auth.BasicAuth{Username: u, Password: p}
}
return auth.Anonymous{}
}Update the two resolve(w, service, repoPath) call sites in handleInfoRefs and
handleRPC to pass r. The 403-on-disabled-push behavior is preserved
(AllowAnonymous{AllowWrite:false} → Deny → 403), and create-on-first-push
still happens only after an Allow.
loadOrCreateHostKey(fs billy.Filesystem) (gossh.Signer, error) reads
.objgit/ssh_host_ed25519_key (PEM) through d.fs. If absent: generate ed25519,
marshal to OpenSSH PEM (gossh.MarshalPrivateKey → pem.EncodeToMemory), write
it back via the filesystem, log "created ssh host key", parse into a signer
(gossh.ParsePrivateKey). Use billy.Filesystem open/create — never local disk.
const hostKeyPath = ".objgit/ssh_host_ed25519_key"newSSHServer(d *daemon, addr string) (*ssh.Server, error):
signer, err := loadOrCreateHostKey(d.fs)
if err != nil {
return nil, fmt.Errorf("ssh host key: %w", err)
}
srv := &ssh.Server{
Addr: addr,
Handler: d.handleSSH, // func(ssh.Session)
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
// Accept every key at connect; real authorization is per-command in
// handleSSH via d.authz. Stash the key for the auth.Request.
ctx.SetValue(pubKeyContextKey{}, key)
return true
},
}
srv.AddHostKey(signer)
return srv, niltype pubKeyContextKey struct{}Setting PublicKeyHandler is what makes the server offer pubkey auth; returning
true unconditionally is the "accept the connection" half — the Authorizer is
the half that gates repo access.
gliderlabs/ssh has already shlex-split the exec command, so s.Command() returns
e.g. ["git-upload-pack", "/foo/bar.git"] — no manual parsing.
-
Reject interactive / malformed. If
len(s.Command()) != 2: friendly line tos.Stderr()("this is a git SSH endpoint; interactive shells are not supported") +s.Exit(1). -
Map
cmd[0]→ service:cmd[0]service git-upload-packtransport.UploadPackServicegit-upload-archivetransport.UploadArchiveServicegit-receive-packtransport.ReceivePackServiceUnknown → stderr error +
s.Exit(1). -
Clean path:
repoPath := strings.TrimPrefix(cmd[1], "/")sossh://host/foo.gitand scp-stylehost:foo.gitresolve identically. -
Authorize:
key, _ := s.Context().Value(pubKeyContextKey{}).(ssh.PublicKey) var cred auth.Credential = auth.Anonymous{} if key != nil { cred = auth.PublicKey{Key: key} // ssh.PublicKey satisfies gossh.PublicKey } if d.authz.Authorize(s.Context(), auth.Request{ Repo: repoPath, Operation: operationFor(service), Cred: cred, Transport: "ssh", }) != auth.Allow { fmt.Fprintln(s.Stderr(), "access denied") _ = s.Exit(1) return }
-
Resolve + dispatch. Mirror
handle; the session is reader and writer, wrapped exactly as git:// does (see gotchas):
r := io.NopCloser(s)
w := ioutil.WriteNopCloser(s)
switch service {
case transport.UploadPackService:
st, err := d.loader.Load(&url.URL{Path: repoPath})
if err != nil {
fmt.Fprintf(s.Stderr(), "repository %q not found\n", repoPath)
_ = s.Exit(1)
return
}
if err := transport.UploadPack(s.Context(), st, r, w, &transport.UploadPackRequest{}); err != nil {
slog.Error("ssh upload-pack failed", "path", repoPath, "err", err)
}
case transport.UploadArchiveService:
st, err := d.loader.Load(&url.URL{Path: repoPath})
if err != nil {
fmt.Fprintf(s.Stderr(), "repository %q not found\n", repoPath)
_ = s.Exit(1)
return
}
if err := transport.UploadArchive(s.Context(), st, r, w, &transport.UploadArchiveRequest{}); err != nil {
slog.Error("ssh upload-archive failed", "path", repoPath, "err", err)
}
case transport.ReceivePackService:
st, err := d.loadOrInit(repoPath)
if err != nil {
fmt.Fprintf(s.Stderr(), "cannot open repository %q\n", repoPath)
_ = s.Exit(1)
return
}
if err := d.receivePack(s.Context(), streamingStorer{Storer: st}, st, repoPath, r, w, &transport.ReceivePackRequest{}); err != nil {
slog.Error("ssh receive-pack failed", "path", repoPath, "err", err)
}
}s.Context() satisfies context.Context, threading cancellation into the
transport calls. (Git protocol v2 over SSH arrives via the GIT_PROTOCOL
env in an setenv request; gliderlabs/ssh gates env with a LocalPortForwarding-
style allowlist — defaulting to v0/v1 negotiation is fine and matches what the
git:// path does when ExtraParams is empty.)
SSH is a persistent stream like git://, so both git:// workarounds apply — treating SSH like HTTP here is the failure mode:
- Hide
PackfileWriterfor receive-pack — wrap the storer in the existingstreamingStorer{}(ingit_protocol.go) soUpdateObjectStoragetakes theParser.Parsepath, not theio.CopyBuffer-until-io.EOFpath that deadlocks on a live socket waiting for report-status. Same loose-objects trade-off as git:// (one S3 PUT per object). - No-op closers —
transport.*callsCloseon the reader (and sometimes the writer) between negotiation rounds; anssh.Session's realClosetears down the channel. Useio.NopCloser(s)andioutil.WriteNopCloser(s)(github.com/go-git/go-git/v6/utils/ioutil).
Receive-pack dispatches through d.receivePack (the wrapper), not
transport.ReceivePack, so push hooks fire over SSH exactly as over git:// / HTTP.
- Add flag near the others:
sshBind = flag.String("ssh-bind", "", "TCP address to listen on for the git-over-SSH protocol; empty disables it")
- Relax the "at least one transport" guard to include
*sshBind. - Build the daemon with the default authorizer and no
allowPushfield:d := &daemon{ fs: fsys, loader: transport.NewFilesystemLoader(fsys, false), authz: auth.AllowAnonymous{AllowWrite: *allowPush}, allowHooks: *allowHooks, hookTimeout: *hookTimeout, }
- Add
"ssh_bind", *sshBindto the"objgitd listening"slog line. - In the errgroup, when
*sshBind != "":(srv, err := newSSHServer(d, *sshBind) if err != nil { slog.Error("can't create ssh server", "ssh_bind", *sshBind, "err", err) os.Exit(1) } g.Go(func() error { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { return err } return nil }) g.Go(func() error { <-gCtx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return srv.Shutdown(shutdownCtx) })
gliderlabs/sshexposesErrServerClosed,ListenAndServe,Shutdownmirroringnet/http.)
AllowAnonymous decisions:
| AllowWrite | Operation | want |
|---|---|---|
| false | Read | Allow |
| false | Write | Deny |
| true | Read | Allow |
| true | Write | Allow |
One table, tt, asserting (AllowAnonymous{tt.allowWrite}).Authorize(ctx, auth.Request{Operation: tt.op}) == tt.want.
The existing git:// and HTTP push-rejected-when-disabled tests must still pass
unchanged — they now exercise the authz path. If git_protocol_test.go /
http_test.go lack an explicit "anonymous read still works when push disabled"
case, add one to each so the retrofit's read-path is covered.
Reuse seedRepo / runGit / tryGit from git_protocol_test.go. Helper
startSSHServer(t, allowPush, allowHooks) (addr string, fs billy.Filesystem)
(t.Helper()): memfs-backed daemon with authz: auth.AllowAnonymous{AllowWrite: allowPush},
listen on 127.0.0.1:0, serve newSSHServer's server in a goroutine, return the
resolved addr. Skip the subtest if ssh/ssh-keygen are missing.
Client identity via env on runGit:
// ssh-keygen -t ed25519 -N "" -f <tmp>/id_ed25519
env := append(os.Environ(),
"GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
"GIT_TERMINAL_PROMPT=0",
)URL: ssh://git@127.0.0.1:<port>/test.git.
Cases (one table, tt, subtests):
- push then clone round-trips (
allowPush: true): push aseedRepocommit, assert/test.git/configin memfs, clone back,git rev-parse HEADnon-empty. - push creates repo on demand (
allowPush: true): push to a not-yet-existing path succeeds and creates the bare repo. - push rejected when disabled (
allowPush: false): push fails;/test.git/confignever created (authzDenyfor receive-pack). - fetch from missing repo fails: clone of a nonexistent path exits non-zero.
- hook fires on push (
allowPush: true,allowHooks: true): push a branch carrying.objgit/hooks/receive-pack; assert the hook's effect using the existing hook test's assertion pattern.
gofmt/goimportsclean;go build ./....go test ./...—internal/auth, git://, HTTP, and SSH suites all pass.go mod tidyleavesgithub.com/gliderlabs/ssh(andgolang.org/x/crypto) resolved with no unexpected churn.- Manual smoke against a real bucket:
Confirm a second run reuses the persisted host key (no host-key-changed warning), and that
objgitd -bucket $BUCKET -ssh-bind :2222 -allow-push GIT_SSH_COMMAND='ssh -p 2222 -o StrictHostKeyChecking=no' \ git clone ssh://git@localhost/smoke.git (cd smoke && git push ssh://git@localhost:2222/smoke.git main) # creates + pushes git clone ssh://git@localhost:2222/smoke.git verify # round-trips-http-bind/-git-bindstill honor-allow-pushthrough the shared authz.
- Task 1 —
internal/authpackage.auth.go(interface, credentials,Decision,AllowAnonymous) +auth_test.gotable.go test ./internal/auth/.... - Task 2 —
authzon daemon + git:// retrofit. Addauthz auth.Authorizertodaemon, removeallowPushfield, addoperationFor, routehandlethrough authz. Updatemain.goto constructAllowAnonymous. Existing git:// tests pass. - Task 3 — HTTP retrofit.
credFromRequest,resolve(w, r, service, repoPath)with 401/403/Allow handling; update the two call sites. Existing HTTP tests pass; add the anonymous-read parity case. - Task 4 — Host key.
loadOrCreateHostKeyoverd.fs; unit test on memfs (first call creates+writes, second reads identical bytes). - Task 5 — SSH server + dispatch.
newSSHServer,pubKeyContextKey,PublicKeyHandler,handleSSHwith the git://-style wrapping and authz check. - Task 6 — main.go SSH wiring.
-ssh-bindflag, errgroup listener + graceful shutdown, slog line.go build -o objgitd ./cmd/objgitd. - Task 7 — SSH tests.
ssh_test.gotable per the cases above; TDD each case. - Task 8 — Docs. Add the auth seam + SSH transport to
CLAUDE.md.