Compare commits

...
Author SHA1 Message Date
hanzo-dev 2f3d2b6a53 sandbox: say 'nothing to exclude' instead of spelling it as forty zeros
A branch the forge does not have advertises the zero hash. Passing that to
packOut worked only because git cat-file happens to fail on it — an accident
standing in for an intention. Live proof re-run after the change: b48c4db6.
2026-08-06 06:34:33 -07:00
hanzo-dev 9a0c4a8f1b sandbox: cloud pushes the sandbox's commits, so no credential is ever in the pod
A sandbox could commit but not push, because it holds no credential. The
deleted daemon solved that by writing a token to /tmp at 0600 — readable by
the very code it was sandboxing. Every variant of putting the credential in
the pod has that shape, and a credential helper only swaps the secret for the
capability.

So the push does not happen in the sandbox. Cloud reads the commits (a read,
needing no credential) and pushes them itself. There is nothing in the pod to
steal, which makes the property structural rather than a small window.

POST /v1/sandboxes/:id/push. The sandbox builds a packfile with git's own
plumbing; it leaves through the existing exec channel, base64'd and chunked
under the 1 MiB stdout cap, checksummed end to end because that buffer
truncates quietly. Cloud streams those exact bytes to the forge over
receive-pack, so the commits that land are the sandbox's own — author, message
and parents intact — rather than one re-synthesized from a file list.

The credential comes from KMS at orgs/<org>/forge/push and fails closed: no
shared-token fallback, nothing minted, https only. The fast-forward check is
ours to make at this level of the protocol — receive-pack does not enforce it
and git's client does — so without it every push would silently be a force.

Live: sandbox-live-push-1786022970 committed 35116874 and cloud landed it on
git.hanzo.ai/hanzo/sandbox-push-proof, branch sandbox-proof-1786022997, with
the pod verified to hold no copy of the token.
2026-08-06 06:32:41 -07:00
4 changed files with 1366 additions and 0 deletions
+637
View File
@@ -0,0 +1,637 @@
package sandbox
// push.go — how a sandbox's commits reach the forge with NO credential ever
// entering the sandbox.
//
// THE PROBLEM. A sandbox can already commit: it has git, a volume and the user's
// code. It cannot push, because it holds no credential. The obvious fix — hand it
// one — is the bug this package was rewritten to delete. The predecessor wrote a
// token to /tmp at 0600 for the duration of a push; the code being sandboxed ran
// as the SAME uid, so it could poll /tmp and take it. Every variant of "put the
// credential in the pod" has that shape. A shorter-lived token shrinks the window;
// a unix-socket credential helper removes the secret but leaves the CAPABILITY,
// which is the same authority wearing a nicer hat — the sandboxed code can still
// ask the helper to push whatever it likes, whenever it likes. Those are arguments
// that the window is SMALL. A sandbox exists precisely because we do not trust
// what runs in it, so "small window" is the wrong kind of answer.
//
// THE DECISION. The credential does not go in the sandbox, because the push does
// not happen in the sandbox. Cloud reads the sandbox's commits — a READ, needing
// no credential — and performs the push itself, from a process the tenant's code
// does not run in. There is no token in the pod to steal and no capability in the
// pod to abuse, so the property is STRUCTURAL rather than temporal. It is the same
// move this package already made when it addressed pods by name instead of by IP:
// make the bad thing unspellable rather than unlikely.
//
// AUTHORITY FLOWS INWARD, NOT OUTWARD. In every rejected design the sandbox proves
// it may push. Here a caller who has ALREADY proven it may push asks cloud to move
// bytes. That principal is the ordinary IAM one the edge terminated, and load()
// scopes the sandbox to its org exactly as every other verb does. Nothing new is
// minted and no new authority exists — which is also how this obeys "never build
// custom auth": the best per-org forge token is the one you did not have to mint.
//
// WHAT THE SANDBOXED CODE CAN STILL DO is stage content — it owns its workspace,
// so of course it decides what the commits contain. What it CANNOT do is push, or
// cause a push, or reach any repo other than the one an authenticated caller
// named. "The agent wrote bad code" is a product property; "the agent stole the
// tenant's forge token" is the bug, and it is gone.
//
// THE TRANSFER IS REAL GIT OBJECTS, not a file list. cloud/apps/git already has a
// client-less push that rebuilds a tree from posted files, and using it here would
// have thrown away the sandbox's actual commits — author, message, parents,
// history — and re-synthesized one commit per call, at O(repo) every time. Instead
// the sandbox produces a PACKFILE with git's own plumbing and cloud streams that
// packfile to the forge over git's own receive-pack protocol. The commits that
// land are byte-identical to the commits the sandbox made.
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/protocol/packp"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/client"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/hanzoai/cloud/brand"
"github.com/zap-proto/zip"
)
// runner runs ONE command inside a sandbox and answers what it produced.
//
// It is a function and not the *runtime, because extracting a packfile does not
// need to know that a sandbox is a pod. What it needs is "a way to run a command
// in there and get the bytes back", which is one signature; giving it the whole
// runtime would let it grow a dependency on the apiserver it has no business
// having, and would make every test of the read-out protocol need a fake cluster.
type runner func(ctx context.Context, argv []string, stdin io.Reader) (ExecResult, error)
// secretReader is the sliver of KMS this file needs: one read, by coordinate. It
// is named here rather than taken as cloud.KMSClient so a test can supply the one
// method instead of a signing, sealing, deleting client it would never call.
type secretReader interface {
GetSecret(ctx context.Context, ref string) ([]byte, error)
}
// chunkBytes is how many RAW packfile bytes each read-out exec carries.
//
// The exec channel's stdout is a 1 MiB-capped string (runtime.go, capped) and
// base64 costs 4/3, so 512 KiB in is ~683 KiB out — under the cap with room to
// spare for a shell's stray newline. Over the cap the buffer TRUNCATES and appends
// a marker, so an oversized chunk would corrupt the pack. The sha256 check below
// would catch that, but a transfer sized to fit is better than one sized to be
// caught.
const chunkBytes = 512 << 10
// maxPackBytes bounds one push. A sandbox is somebody else's code and the pack it
// offers is therefore somebody else's number; without a ceiling a single call
// could ask cloud to hold an arbitrary amount of it in memory.
const maxPackBytes = 256 << 20
// PushSpec asks for the sandbox's commits to be landed on the forge.
type PushSpec struct {
// Repo is the repository on the forge, under the org's forge owner. It is the
// only part of the destination a caller chooses; the owner comes from the
// org's sealed credential, so a request cannot aim a push at another tenant.
Repo string `json:"repo"`
// Branch is the local branch in the sandbox to push. Empty means HEAD.
Branch string `json:"branch"`
// RemoteBranch is the branch to advance on the forge; empty means the same
// name as Branch. Naming a branch is how an agent's work lands somewhere a
// human reviews rather than straight onto the trunk.
RemoteBranch string `json:"remoteBranch"`
// Dir is the repository's path inside the sandbox; empty means the workdir.
Dir string `json:"dir"`
// Force allows a non-fast-forward update. Off by default: an agent that
// rewrote history should have to say so.
Force bool `json:"force"`
}
// PushResult reports what landed.
type PushResult struct {
// Commit is the tip that now exists on the forge.
Commit string `json:"commit"`
// Branch is the forge branch that was advanced.
Branch string `json:"branch"`
// Previous is the tip the branch held before, or "" for a new branch. It is
// reported because "what did I move" is not recoverable afterwards.
Previous string `json:"previous,omitempty"`
// RemoteURL is the repository the commits landed in.
RemoteURL string `json:"remoteUrl"`
// PackBytes is the size of the object transfer. An INCREMENTAL push sends
// only what the forge lacked, so this is small on the second push and is the
// number that says so.
PackBytes int `json:"packBytes"`
// UpToDate is true when the forge already had that commit and nothing moved.
UpToDate bool `json:"upToDate"`
}
// Push lands the sandbox's commits on the org's forge.
//
// The org comes from the validated principal (load), the destination owner and
// credential come from KMS under that org, and the objects come from the sandbox.
// No credential is sent to the sandbox at any point in this function.
func Push(s *Service, c *zip.Ctx) error {
m, store, err := load(s, c)
if err != nil {
return err
}
org, _ := orgOf(c)
var in PushSpec
if err := c.Bind(&in); err != nil {
return err
}
run := func(ctx context.Context, argv []string, stdin io.Reader) (ExecResult, error) {
return s.State.rt.exec(ctx, m, argv, stdin, 0)
}
out, err := pushFromSandbox(c.Context(), run, s.KMS, s.Domain, org, in)
if err != nil {
return err
}
touch(c, store, m)
return c.JSON(http.StatusOK, out)
}
// pushFromSandbox is the whole operation with no HTTP and no Kubernetes in it: a
// way to run commands in the sandbox, a place to read the credential from, and
// the request. Every decision it makes is testable without either.
//
// It is two steps because they answer to different authorities. WHICH credential
// this org may use is a KMS and IAM question, settled once, under policy that
// does not vary per request. WHAT to push with it is the caller's request. Keeping
// them separate is what lets the transfer be exercised end to end against a real
// git server without a test having to weaken the https rule to do it.
func pushFromSandbox(ctx context.Context, run runner, kms secretReader,
domain, org string, in PushSpec) (*PushResult, error) {
cred, err := forgeCredential(ctx, kms, domain, org)
if err != nil {
return nil, err
}
return pushTo(ctx, run, cred, in)
}
// pushTo moves the sandbox's commits to an already-resolved destination.
func pushTo(ctx context.Context, run runner, cred forgeCred, in PushSpec) (*PushResult, error) {
repo := strings.TrimSpace(in.Repo)
if repo == "" {
return nil, zip.ErrBadRequest("repo is required")
}
if err := checkRepoName(repo); err != nil {
return nil, err
}
local := firstNonEmpty(strings.TrimSpace(in.Branch), "HEAD")
remoteBranch := strings.TrimSpace(in.RemoteBranch)
if remoteBranch == "" && local != "HEAD" {
remoteBranch = local
}
if remoteBranch == "" {
return nil, zip.ErrBadRequest("remoteBranch is required when pushing HEAD")
}
if err := checkBranch(remoteBranch); err != nil {
return nil, err
}
dir := firstNonEmpty(strings.TrimSpace(in.Dir), workdir)
remoteURL := cred.Host + "/" + cred.Owner + "/" + repo + ".git"
// Open the receive-pack session FIRST. Its advertisement is what the forge
// already has, which is both the old value the update command needs and the
// exclusion that makes the pack incremental — so asking the forge first is
// not an extra round trip, it is what makes the transfer small.
sess, adv, err := receiveSession(ctx, remoteURL, cred)
if err != nil {
return nil, err
}
defer func() { _ = sess.Close() }()
ref := plumbing.NewBranchReferenceName(remoteBranch)
old := adv.References[ref.String()]
// A branch the forge does not have yet advertises the zero hash. Passing that
// along would "work" only because `git cat-file -e` happens to fail on it —
// say the real thing instead: there is nothing to exclude and nothing to
// fast-forward from.
have := ""
if !old.IsZero() {
have = old.String()
}
p, err := packOut(ctx, run, dir, local, have)
if err != nil {
return nil, err
}
tip := p.tip
if tip == old.String() {
return &PushResult{Commit: tip, Branch: remoteBranch, Previous: old.String(),
RemoteURL: remoteURL, UpToDate: true}, nil
}
// THE FAST-FORWARD CHECK IS OURS TO MAKE. At this level of the protocol the
// update command carries no force bit — `git push` enforces fast-forward in
// the CLIENT and receive-pack only refuses when the server was configured to
// (denyNonFastForwards, off by default on a bare repo). So without this, every
// push here would silently be a force push and `Force` would be decoration.
if !old.IsZero() && !p.fastForward && !in.Force {
return nil, zip.Errorf(http.StatusConflict,
"%s on the forge is at %s, which is not an ancestor of %s — "+
"fetch and rebase in the sandbox, or push with force",
remoteBranch, old.String()[:12], tip[:12])
}
req := packp.NewReferenceUpdateRequestFromCapabilities(adv.Capabilities)
req.Commands = []*packp.Command{{Name: ref, Old: old, New: plumbing.NewHash(tip)}}
req.Packfile = io.NopCloser(bytes.NewReader(p.pack))
report, err := sess.ReceivePack(ctx, req)
if err != nil {
return nil, zip.Errorf(http.StatusBadGateway, "forge rejected the push: %v", err)
}
if report != nil {
if err := report.Error(); err != nil {
// A non-fast-forward lands here. It is the forge's answer, not a
// transport failure, so it is reported as the caller's problem.
return nil, zip.Errorf(http.StatusConflict, "forge rejected %s: %v", remoteBranch, err)
}
}
prev := ""
if !old.IsZero() {
prev = old.String()
}
return &PushResult{Commit: tip, Branch: remoteBranch, Previous: prev,
RemoteURL: remoteURL, PackBytes: len(p.pack)}, nil
}
// ── getting the objects out ────────────────────────────────────────────────────
// packed is what came out of the sandbox: the objects, the tip they lead to, and
// whether the forge's current tip is an ancestor of it. The third fact rides with
// the other two because the sandbox is the only party that HOLDS both commits and
// can answer it — cloud never has the history, only the delta.
type packed struct {
pack []byte
tip string
fastForward bool
}
// packOut builds a packfile of everything reachable from rev that `have` does not
// already cover, and carries it out of the sandbox.
//
// NOTHING SECRET GOES IN. The commands below read the tenant's own repository and
// write a temp file the tenant's own code could read anyway; there is no token in
// any argv, in any env, or on that filesystem. That asymmetry is the whole design:
// objects flowing OUT of a sandbox are harmless, credentials flowing IN are not.
//
// The exec channel carries a JSON string, so the raw pack is base64'd INSIDE the
// pod — binary would not survive the trip — and read in bounded chunks, because a
// single 1 MiB-capped stdout cannot hold a repository. The sandbox reports the
// file's size and sha256 before the first chunk, and this function verifies both
// after the last: the capped buffer truncates SILENTLY-ish on overflow, and a
// packfile that is short by one byte is exactly the kind of corruption that would
// otherwise be discovered by whoever cloned the repo a week later.
func packOut(ctx context.Context, run runner, dir, rev, have string) (packed, error) {
tmp := "/tmp/.hz-push-" + nonce()
defer func() {
// Best effort: the sandbox is leased and reaped anyway, so a failed
// cleanup is not worth failing a good push over.
_, _ = run(ctx, []string{"sh", "-c", "rm -f -- " + shellQuote(tmp)}, nil)
}()
build := "set -e\n" +
"cd " + shellQuote(dir) + "\n" +
"tip=$(git rev-parse --verify " + shellQuote(rev+"^{commit}") + ")\n" +
"have=" + shellQuote(have) + "\n" +
"ff=no\n" +
// A `have` the sandbox does not hold cannot be excluded from the pack —
// which happens when the forge is ahead, or the volume was recycled.
// Falling back to a FULL pack is always correct, only larger, so this is
// an optimisation that degrades silently rather than a check that fails.
// It is also where fast-forward is decided, because holding both commits
// is exactly what it takes to answer that.
"if [ -n \"$have\" ] && git cat-file -e \"$have^{commit}\" 2>/dev/null; then\n" +
" if git merge-base --is-ancestor \"$have\" \"$tip\"; then ff=yes; fi\n" +
" printf '%s\\n^%s\\n' \"$tip\" \"$have\" | git pack-objects --stdout --revs > " + shellQuote(tmp) + "\n" +
"else\n" +
" printf '%s\\n' \"$tip\" | git pack-objects --stdout --revs > " + shellQuote(tmp) + "\n" +
"fi\n" +
"printf 'tip %s\\nsize %s\\nsha %s\\nff %s\\n' \"$tip\" \"$(wc -c < " + shellQuote(tmp) + ")\" " +
"\"$(sha256sum " + shellQuote(tmp) + " | cut -d' ' -f1)\" \"$ff\"\n"
r, err := run(ctx, []string{"sh", "-c", build}, nil)
if err != nil {
return packed{}, zip.Errorf(http.StatusBadGateway, "pack: %v", err)
}
if r.ExitCode != 0 {
return packed{}, zip.Errorf(http.StatusBadRequest, "pack %s in %s: %s",
rev, dir, strings.TrimSpace(firstNonEmpty(r.Stderr, r.Stdout, "no such revision")))
}
meta, err := parsePackMeta(r.Stdout)
if err != nil {
return packed{}, err
}
if meta.size > maxPackBytes {
return packed{}, zip.Errorf(http.StatusRequestEntityTooLarge,
"pack is %d bytes, over the %d limit", meta.size, maxPackBytes)
}
sum := sha256.New()
buf := make([]byte, 0, meta.size)
for off := 0; off < meta.size; off += chunkBytes {
cmd := "dd if=" + shellQuote(tmp) + " bs=" + strconv.Itoa(chunkBytes) +
" skip=" + strconv.Itoa(off/chunkBytes) + " count=1 2>/dev/null | base64 | tr -d '\\n'"
cr, err := run(ctx, []string{"sh", "-c", cmd}, nil)
if err != nil {
return packed{}, zip.Errorf(http.StatusBadGateway, "pack read at %d: %v", off, err)
}
if cr.ExitCode != 0 {
return packed{}, zip.Errorf(http.StatusBadGateway, "pack read at %d: %s",
off, strings.TrimSpace(cr.Stderr))
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cr.Stdout))
if err != nil {
// The capped buffer appends a marker when it overflows, and that
// marker is not base64 — so a chunk sized wrong announces itself here
// rather than as a corrupt repository later.
return packed{}, zip.Errorf(http.StatusBadGateway,
"pack chunk at %d did not decode (truncated?): %v", off, err)
}
sum.Write(raw)
buf = append(buf, raw...)
}
if len(buf) != meta.size {
return packed{}, zip.Errorf(http.StatusBadGateway,
"pack is %d bytes, the sandbox reported %d", len(buf), meta.size)
}
if got := hex.EncodeToString(sum.Sum(nil)); got != meta.sha {
return packed{}, zip.Errorf(http.StatusBadGateway,
"pack checksum %s does not match the sandbox's %s", got, meta.sha)
}
return packed{pack: buf, tip: meta.tip, fastForward: meta.ff}, nil
}
// packMeta is what the build step printed about the pack it just wrote.
type packMeta struct {
tip string
sha string
size int
ff bool
}
// parsePackMeta reads those facts. It is STRICT: a missing or malformed line
// means the shell did something other than what was asked, and guessing at that
// is how a corrupt push gets built. Unknown keys are ignored so a future line
// does not break an older reader, but the four that matter must all be there.
func parsePackMeta(out string) (packMeta, error) {
var m packMeta
bad := func(f string, a ...any) (packMeta, error) {
return packMeta{}, zip.Errorf(http.StatusBadGateway, f, a...)
}
for _, line := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), " ")
if !ok {
continue
}
v = strings.TrimSpace(v)
switch k {
case "tip":
m.tip = v
case "sha":
m.sha = v
case "ff":
m.ff = v == "yes"
case "size":
n, err := strconv.Atoi(v)
if err != nil {
return bad("pack size %q: %v", v, err)
}
m.size = n
}
}
if len(m.tip) != 40 || len(m.sha) != 64 || m.size <= 0 {
return bad("sandbox reported an unusable pack (tip=%q size=%d sha=%q)", m.tip, m.size, m.sha)
}
return m, nil
}
// ── the credential, which lives in KMS and never in the sandbox ────────────────
// forgeCred is an org's push credential for the forge.
type forgeCred struct {
// Host is the forge base URL. Empty falls back to the deployment's own forge
// (the "git" sibling of its apex), so a normal deployment seals three fields
// and a white-label one seals four.
Host string `json:"host"`
// Owner is the namespace the org's repositories live under ON THE FORGE.
//
// It is DATA rather than the org name, because they are different namespaces:
// cloud's org is "hanzo" and the forge's owner is "hanzoai". Guessing that
// mapping would either fail closed on every org whose names differ, or — much
// worse — push one tenant's commits into a namespace that happens to match
// another's.
Owner string `json:"owner"`
// User is the forge account the token belongs to (basic-auth username).
User string `json:"user"`
// Token is the forge access token. It is read per push, held for the length
// of one call, never written down and never logged.
Token string `json:"token"`
}
// forgeSecretRef is the org-scoped KMS coordinate of that credential.
//
// It is INJECTIVE in org — the validated IAM owner, verbatim — so one tenant's
// credential is not addressable as another's. The second segment is "forge",
// disjoint from the "platform" segment app secrets use and the "kms-auth" segment
// the KMS-sync identity uses, so a tenant app can neither read nor seal onto it.
//
// The four fields are ONE secret rather than four coordinates (which is the shape
// kmsAuthRef uses for its pair) because they are only meaningful together: a token
// rotated against a stale owner would push a tenant's commits at the wrong
// namespace, and separate keys can tear. One read, one atomic unit.
func forgeSecretRef(org string) string { return "orgs/" + org + "/forge/push" }
// forgeCredential resolves the org's forge credential from KMS.
//
// FAIL CLOSED. There is no fallback to a shared platform token, and nothing here
// mints anything. The credential's PRESENCE in KMS is the org's opt-in and its
// ABSENCE is a refusal — the same contract the per-tenant KMS-sync identity uses,
// for the same reason: one shared credential presented for every tenant is the
// cross-tenant hole that a per-org credential exists to close, and it is worse
// than the feature being off.
func forgeCredential(ctx context.Context, kms secretReader, domain, org string) (forgeCred, error) {
if org == "" {
return forgeCred{}, zip.ErrForbidden("X-Org-Id required")
}
if kms == nil || isNilKMS(kms) {
return forgeCred{}, zip.Errorf(http.StatusServiceUnavailable,
"no KMS in this process, so no forge credential can be read")
}
raw, err := kms.GetSecret(ctx, forgeSecretRef(org))
if err != nil || len(bytes.TrimSpace(raw)) == 0 {
return forgeCred{}, zip.Errorf(http.StatusPreconditionRequired,
"no forge credential for org %q: seal one at %s to enable pushing",
org, forgeSecretRef(org))
}
var cred forgeCred
if err := json.Unmarshal(raw, &cred); err != nil {
return forgeCred{}, zip.Errorf(http.StatusPreconditionRequired,
"forge credential at %s is not the expected JSON object", forgeSecretRef(org))
}
cred.Owner, cred.User = strings.TrimSpace(cred.Owner), strings.TrimSpace(cred.User)
cred.Token, cred.Host = strings.TrimSpace(cred.Token), strings.TrimSpace(cred.Host)
if cred.Owner == "" || cred.Token == "" {
return forgeCred{}, zip.Errorf(http.StatusPreconditionRequired,
"forge credential at %s needs both owner and token", forgeSecretRef(org))
}
if cred.Host == "" {
cred.Host = "https://" + forgeHost(domain)
}
cred.Host = strings.TrimSuffix(cred.Host, "/")
if !strings.HasPrefix(cred.Host, "https://") {
// A push carries a bearer credential, so plaintext is not a preference.
return forgeCred{}, zip.Errorf(http.StatusPreconditionRequired,
"forge host %q must be https", cred.Host)
}
return cred, nil
}
// forgeHost is the deployment's own forge: the "git" sibling of its apex
// (api.hanzo.ai → git.hanzo.ai), falling back to the Hanzo brand's when the
// deployment names no domain. Same derivation apps/git uses, so a white-label
// deployment reaches its own forge without a second knob.
func forgeHost(domain string) string {
if h := brand.Sibling(strings.TrimSpace(domain), "git"); h != "" && h != "git." {
return h
}
return "git.hanzo.ai"
}
// ── the push itself ────────────────────────────────────────────────────────────
// receiveSession opens a git-receive-pack session and reads the forge's ref
// advertisement.
//
// This is git's OWN push protocol at its own level, deliberately, rather than
// go-git's Repository.Push. Push wants a complete local repository to negotiate
// from, and cloud does not have one and must not build one: the pack that came out
// of the sandbox is INCREMENTAL — it holds what the forge lacked and nothing more.
// Feeding those bytes straight to receive-pack is both the cheapest thing and the
// only correct one, since there is no re-encoding step to get wrong.
func receiveSession(ctx context.Context, remoteURL string, cred forgeCred) (
transport.ReceivePackSession, *packp.AdvRefs, error) {
ep, err := transport.NewEndpoint(remoteURL)
if err != nil {
return nil, nil, zip.ErrBadRequest("forge url: " + err.Error())
}
cl, err := client.NewClient(ep)
if err != nil {
return nil, nil, zip.Errorf(http.StatusBadGateway, "forge transport: %v", err)
}
// Basic auth over https. The token is presented to the forge and nowhere
// else — it is never an argv, never an env var, and never reaches the pod.
auth := &githttp.BasicAuth{Username: firstNonEmpty(cred.User, cred.Owner), Password: cred.Token}
sess, err := cl.NewReceivePackSession(ep, auth)
if err != nil {
return nil, nil, zip.Errorf(http.StatusBadGateway, "forge session: %v", err)
}
adv, err := sess.AdvertisedReferencesContext(ctx)
if errors.Is(err, transport.ErrEmptyRemoteRepository) {
// A repository that exists and holds nothing is the FIRST push, which is
// the normal case for a new project, not an error.
adv = packp.NewAdvRefs()
err = nil
}
if err != nil {
_ = sess.Close()
if errors.Is(err, transport.ErrRepositoryNotFound) {
return nil, nil, zip.ErrNotFound("no such repository on the forge: " + remoteURL)
}
if errors.Is(err, transport.ErrAuthenticationRequired) || errors.Is(err, transport.ErrAuthorizationFailed) {
return nil, nil, zip.Errorf(http.StatusForbidden,
"the org's forge credential cannot push to %s", remoteURL)
}
return nil, nil, zip.Errorf(http.StatusBadGateway, "forge advertisement: %v", err)
}
if adv.References == nil {
adv.References = map[string]plumbing.Hash{}
}
return sess, adv, nil
}
// ── small, shared checks ───────────────────────────────────────────────────────
// checkRepoName refuses anything that is not a plain repository name. The value
// becomes a URL path segment, so a "../" or a host in it would aim the push
// somewhere the caller does not own.
func checkRepoName(name string) error {
if len(name) > 100 {
return zip.ErrBadRequest("repo name is too long")
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '-', r == '_', r == '.':
default:
return zip.ErrBadRequest("repo name may only hold letters, digits, '-', '_' and '.'")
}
}
if name == "." || name == ".." || strings.HasPrefix(name, ".") {
return zip.ErrBadRequest("repo name may not start with '.'")
}
return nil
}
// checkBranch refuses ref names git itself would refuse, plus the ones that would
// let a caller write outside refs/heads.
func checkBranch(b string) error {
if len(b) > 200 {
return zip.ErrBadRequest("branch name is too long")
}
if b == "" || strings.HasPrefix(b, "/") || strings.HasSuffix(b, "/") ||
strings.HasPrefix(b, "-") || strings.Contains(b, "..") ||
strings.Contains(b, "//") || strings.HasSuffix(b, ".lock") {
return zip.ErrBadRequest("invalid branch name")
}
for _, r := range b {
if r <= ' ' || r == 0x7f || strings.ContainsRune("~^:?*[\\", r) {
return zip.ErrBadRequest("invalid branch name")
}
}
if !plumbing.NewBranchReferenceName(b).IsBranch() {
return zip.ErrBadRequest("invalid branch name")
}
return nil
}
// nonce names the sandbox-side temp file. It is random so two concurrent pushes
// in one sandbox cannot read each other's half-written pack.
func nonce() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand does not fail in practice; if it ever does, a colliding
// name is far better than a push that silently uses a fixed one.
return fmt.Sprintf("%d", len(b))
}
return hex.EncodeToString(b[:])
}
// isNilKMS reports whether the interface holds a TYPED nil. `k == nil` misses
// that case — a nil *client boxed in an interface is not a nil interface — and
// the miss would be a panic in a handler rather than the honest 503 below.
func isNilKMS(k secretReader) bool {
if k == nil {
return true
}
v := reflect.ValueOf(k)
switch v.Kind() {
case reflect.Ptr, reflect.Interface, reflect.Map, reflect.Slice, reflect.Func:
return v.IsNil()
}
return false
}
+129
View File
@@ -0,0 +1,129 @@
package sandbox
// The live push proof: a REAL sandbox pod on a REAL cluster makes a REAL commit,
// and that commit lands on the REAL forge — with no credential ever inside the
// pod. Everything the unit tests fake is real here, which is the only way to
// answer the one question they cannot: does the packfile a stock image's git
// produces survive the exec channel and satisfy a production receive-pack.
//
// Skipped unless SANDBOX_PUSH_LIVE=1, because it needs a kubeconfig AND a forge
// credential, and a test that fails on a laptop for want of either says nothing
// about the code. Run it with:
//
// SANDBOX_PUSH_LIVE=1 \
// SANDBOX_NAMESPACE=hanzo-sandboxes \
// FORGE_OWNER=hanzo FORGE_REPO=sandbox-push-proof \
// FORGE_USER=z FORGE_TOKEN=... \
// go test ./apps/sandbox/ -run TestLivePush -v
//
// FORGE_TOKEN is read from the environment HERE and nowhere else. In production
// it comes from KMS (forgeCredential) and is never an env var at all — a test
// harness is allowed to hand a credential to the process doing the pushing,
// because that process is cloud. What neither is allowed to do is put it in the
// pod, and this test never does: watch the commands packOut issues.
import (
"context"
"fmt"
"io"
"os"
"strings"
"testing"
"time"
)
func TestLivePushFromSandboxToForge(t *testing.T) {
if os.Getenv("SANDBOX_PUSH_LIVE") != "1" {
t.Skip("set SANDBOX_PUSH_LIVE=1 to push from a real sandbox to a real forge")
}
token := os.Getenv("FORGE_TOKEN")
owner := envOr("FORGE_OWNER", "hanzo")
repo := envOr("FORGE_REPO", "sandbox-push-proof")
if token == "" {
t.Fatal("FORGE_TOKEN is required")
}
r := newRuntime()
if err := r.ready(); err != nil {
t.Fatalf("no cluster: %v", err)
}
m := Sandbox{
ID: "live-push",
Org: "hanzo",
Status: "running",
Class: "exec",
Pod: fmt.Sprintf("sandbox-live-push-%d", time.Now().Unix()),
Image: envOr("SANDBOX_LIVE_IMAGE", "node:22"),
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()
t.Logf("starting %s image=%s ns=%s", m.Pod, m.Image, r.ns)
if err := r.start(ctx, m); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
if err := r.stop(context.Background(), m); err != nil {
t.Logf("cleanup: %v", err)
}
})
run := func(ctx context.Context, argv []string, stdin io.Reader) (ExecResult, error) {
return r.exec(ctx, m, argv, stdin, 120)
}
// A real repository with a real commit, made by the sandbox — the same thing
// an agent does, and the state this whole exercise starts from.
branch := fmt.Sprintf("sandbox-proof-%d", time.Now().Unix())
setup := strings.Join([]string{
"set -e",
"rm -rf /work/proof && mkdir -p /work/proof && cd /work/proof",
"git init -q -b " + branch,
"git config user.email agent@hanzo.ai",
"git config user.name 'Hanzo Sandbox Agent'",
"node -e \"require('fs').writeFileSync('run.js','console.log(process.version)')\"",
"node run.js > version.txt",
"echo 'pushed by cloud, never by the sandbox' > README.md",
"git add -A",
"git commit -q -m 'sandbox commit, pushed by cloud without a token in the pod'",
"git rev-parse HEAD",
"git --no-pager log -1 --format='%an <%ae> %s'",
"cat version.txt",
}, "\n")
res, err := run(ctx, []string{"sh", "-c", setup}, nil)
if err != nil || res.ExitCode != 0 {
t.Fatalf("setup: err=%v exit=%d\n%s\n%s", err, res.ExitCode, res.Stdout, res.Stderr)
}
lines := strings.Split(strings.TrimSpace(res.Stdout), "\n")
sha := strings.TrimSpace(lines[0])
t.Logf("SANDBOX COMMITTED %s", sha)
for _, l := range lines[1:] {
t.Logf(" %s", strings.TrimSpace(l))
}
// THE POD HOLDS NO CREDENTIAL. Asserted, not assumed: the token is in this
// process's environment, so if any of it had leaked into the pod — an env var,
// a config, a file — this grep would find it.
leak := "grep -rl -- " + shellQuote(token) + " /work /tmp /root /home /etc 2>/dev/null | head -5; " +
"env | grep -c -- " + shellQuote(token) + " || true"
if lr, err := run(ctx, []string{"sh", "-c", leak}, nil); err != nil {
t.Fatalf("leak check: %v", err)
} else if got := strings.TrimSpace(lr.Stdout); got != "0" {
t.Fatalf("THE TOKEN IS REACHABLE INSIDE THE SANDBOX: %q", got)
}
t.Log("NO CREDENTIAL IN THE POD: token not present in env or on disk")
cred := forgeCred{
Host: envOr("FORGE_HOST", "https://git.hanzo.ai"),
Owner: owner, User: envOr("FORGE_USER", "z"), Token: token,
}
out, err := pushTo(ctx, run, cred, PushSpec{
Repo: repo, Branch: branch, RemoteBranch: branch, Dir: "/work/proof"})
if err != nil {
t.Fatalf("push: %v", err)
}
if out.Commit != sha {
t.Fatalf("pushed %s, but the sandbox committed %s", out.Commit, sha)
}
t.Logf("PUSHED %s -> %s branch=%s packBytes=%d", out.Commit, out.RemoteURL, out.Branch, out.PackBytes)
}
+598
View File
@@ -0,0 +1,598 @@
package sandbox
// push_test.go proves the two properties that matter about pushing a sandbox's
// commits, and they are different in kind.
//
// - The TRANSFER is honest. A packfile leaves the pod through a 1 MiB-capped
// text channel, so the interesting cases are all the ways that can go wrong
// quietly: a truncated chunk, a short file, a sha that does not match. Those
// are driven with a fake pod, because the failures are the point and a real
// pod will not produce them on request.
// - The CREDENTIAL fails closed. Absent, malformed, half-filled, or plaintext —
// every one of them refuses, and none of them falls back to a shared token.
//
// And then, once, the whole thing for real: a real git repository standing in for
// the sandbox, a real packfile, a real git-http-backend server that demands basic
// auth, and a real bare repo that must end up holding the exact commit — with the
// author and message the sandbox wrote, which is what says the objects were moved
// rather than re-synthesized.
//
// The os/exec below is the TEST playing the part of the pod. Production has none
// in this package and must never have any: it streams to the apiserver, and the
// work happens on the far side of a runtime boundary.
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
// ── the transfer ───────────────────────────────────────────────────────────────
// fakePod answers the two commands packOut issues without a cluster. Each knob is
// one way the exec channel can lie about what it carried.
type fakePod struct {
pack []byte
tip string
ff string
size int // reported size; 0 means "report the truth"
sha string // reported sha; "" means "report the truth"
corrupt bool // return a chunk that is not base64 — what truncation looks like
drop int // drop this many bytes from the first chunk
exit int // non-zero exit from the build step
stderr string
}
// runner adapts fakePod to the runner signature.
func (f *fakePod) runner() runner {
return func(_ context.Context, argv []string, _ io.Reader) (ExecResult, error) {
cmd := argv[len(argv)-1]
switch {
case strings.Contains(cmd, "rm -f"):
return ExecResult{}, nil
case strings.Contains(cmd, "pack-objects"):
if f.exit != 0 {
return ExecResult{ExitCode: f.exit, Stderr: f.stderr}, nil
}
size, sha := f.size, f.sha
if size == 0 {
size = len(f.pack)
}
if sha == "" {
sha = sha256hex(f.pack)
}
return ExecResult{Stdout: fmt.Sprintf("tip %s\nsize %d\nsha %s\nff %s\n",
f.tip, size, sha, firstNonEmpty(f.ff, "yes"))}, nil
case strings.Contains(cmd, "dd if="):
off := chunkOffsetOf(cmd)
end := min(off+chunkBytes, len(f.pack))
if off > len(f.pack) {
off = len(f.pack)
}
seg := f.pack[off:end]
if f.corrupt {
return ExecResult{Stdout: base64.StdEncoding.EncodeToString(seg) + "\n[truncated at 1MiB]"}, nil
}
if f.drop > 0 && off == 0 && len(seg) > f.drop {
seg = seg[:len(seg)-f.drop]
}
return ExecResult{Stdout: base64.StdEncoding.EncodeToString(seg)}, nil
}
return ExecResult{ExitCode: 127, Stderr: "unexpected command"}, nil
}
}
func TestPackOutVerifiesWhatItCarried(t *testing.T) {
body := make([]byte, chunkBytes+1024) // spans two chunks, so offsets matter
for i := range body {
body[i] = byte(i * 7)
}
const tip = "0123456789abcdef0123456789abcdef01234567"
for _, tc := range []struct {
name string
pod *fakePod
want string // substring of the expected error; "" means it must succeed
ff bool
}{
{name: "carries every byte of a multi-chunk pack",
pod: &fakePod{pack: body, tip: tip}, ff: true},
{name: "a single short chunk is fine",
pod: &fakePod{pack: body[:10], tip: tip}, ff: true},
{name: "reports a non-fast-forward when the sandbox said so",
pod: &fakePod{pack: body[:10], tip: tip, ff: "no"}},
{name: "refuses a chunk the cap truncated",
pod: &fakePod{pack: body, tip: tip, corrupt: true}, want: "did not decode"},
{name: "refuses a pack shorter than announced",
pod: &fakePod{pack: body, tip: tip, drop: 16}, want: "the sandbox reported"},
{name: "refuses a checksum that does not match",
pod: &fakePod{pack: body[:64], tip: tip, sha: strings.Repeat("a", 64)},
want: "checksum"},
{name: "refuses a size that is not a number",
pod: &fakePod{pack: body[:64], tip: tip, size: -1}, want: "unusable pack"},
{name: "refuses a tip that is not a sha",
pod: &fakePod{pack: body[:64], tip: "HEAD"}, want: "unusable pack"},
{name: "reports the sandbox's own failure",
pod: &fakePod{tip: tip, exit: 128, stderr: "fatal: bad revision"},
want: "bad revision"},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := packOut(context.Background(), tc.pod.runner(), "/work", "main", "")
if tc.want != "" {
if err == nil {
t.Fatalf("wanted an error containing %q, got a pack of %d bytes", tc.want, len(got.pack))
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error %q does not mention %q", err, tc.want)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(got.pack) != string(tc.pod.pack) {
t.Fatalf("pack round-tripped wrong: got %d bytes, want %d", len(got.pack), len(tc.pod.pack))
}
if got.tip != tip {
t.Fatalf("tip = %q, want %q", got.tip, tip)
}
if got.fastForward != tc.ff {
t.Fatalf("fastForward = %v, want %v", got.fastForward, tc.ff)
}
})
}
}
func TestParsePackMetaIsStrict(t *testing.T) {
good := "tip " + strings.Repeat("a", 40) + "\nsize 12\nsha " + strings.Repeat("b", 64) + "\nff yes\n"
for _, tc := range []struct {
name, in string
ok bool
}{
{"the four facts", good, true},
{"an unknown line is ignored", good + "future thing\n", true},
{"a missing sha is refused", "tip " + strings.Repeat("a", 40) + "\nsize 12\n", false},
{"a zero size is refused", "tip " + strings.Repeat("a", 40) + "\nsize 0\nsha " + strings.Repeat("b", 64), false},
{"a short tip is refused", "tip abc\nsize 12\nsha " + strings.Repeat("b", 64), false},
{"empty output is refused", "", false},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := parsePackMeta(tc.in)
if tc.ok != (err == nil) {
t.Fatalf("parsePackMeta ok=%v, err=%v", err == nil, err)
}
})
}
}
// ── the credential ─────────────────────────────────────────────────────────────
type fakeKMS struct {
secrets map[string][]byte
asked []string
}
func (k *fakeKMS) GetSecret(_ context.Context, ref string) ([]byte, error) {
k.asked = append(k.asked, ref)
v, ok := k.secrets[ref]
if !ok {
return nil, errors.New("no such secret")
}
return v, nil
}
// nilKMS is a TYPED nil — the shape a plain `== nil` misses.
type nilKMS struct{}
func (*nilKMS) GetSecret(context.Context, string) ([]byte, error) { return nil, nil }
func TestForgeCredentialFailsClosed(t *testing.T) {
ref := forgeSecretRef("acme")
cred := func(v map[string]string) []byte { b, _ := json.Marshal(v); return b }
for _, tc := range []struct {
name string
kms secretReader
want string // error substring; "" means it must resolve
host string // expected resolved host when it resolves
user string
wantRef string
}{
{name: "no KMS in this process", kms: nil, want: "no KMS"},
{name: "a typed-nil KMS does not panic", kms: (*nilKMS)(nil), want: "no KMS"},
{name: "nothing sealed for this org",
kms: &fakeKMS{secrets: map[string][]byte{}}, want: "seal one at " + ref},
{name: "an empty secret is not a credential",
kms: &fakeKMS{secrets: map[string][]byte{ref: []byte(" ")}}, want: "seal one at"},
{name: "a secret that is not JSON",
kms: &fakeKMS{secrets: map[string][]byte{ref: []byte("ghp_token")}}, want: "expected JSON"},
{name: "an owner with no token",
kms: &fakeKMS{secrets: map[string][]byte{ref: cred(map[string]string{"owner": "acme"})}},
want: "owner and token"},
{name: "a token with no owner",
kms: &fakeKMS{secrets: map[string][]byte{ref: cred(map[string]string{"token": "t"})}},
want: "owner and token"},
{name: "a plaintext host is refused",
kms: &fakeKMS{secrets: map[string][]byte{ref: cred(map[string]string{
"owner": "acme", "token": "t", "host": "http://git.internal"})}},
want: "must be https"},
{name: "a complete credential resolves",
kms: &fakeKMS{secrets: map[string][]byte{ref: cred(map[string]string{
"owner": "hanzoai", "user": "bot", "token": "t"})}},
host: "https://git.hanzo.ai", user: "bot", wantRef: ref},
{name: "a white-label host overrides the derived one",
kms: &fakeKMS{secrets: map[string][]byte{ref: cred(map[string]string{
"owner": "acme", "token": "t", "host": "https://git.acme.dev/"})}},
host: "https://git.acme.dev", wantRef: ref},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := forgeCredential(context.Background(), tc.kms, "api.hanzo.ai", "acme")
if tc.want != "" {
if err == nil {
t.Fatalf("wanted an error containing %q, resolved %+v", tc.want, got)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error %q does not mention %q", err, tc.want)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Host != tc.host {
t.Fatalf("host = %q, want %q", got.Host, tc.host)
}
if tc.user != "" && got.User != tc.user {
t.Fatalf("user = %q, want %q", got.User, tc.user)
}
if k, ok := tc.kms.(*fakeKMS); ok && tc.wantRef != "" {
if len(k.asked) != 1 || k.asked[0] != tc.wantRef {
t.Fatalf("read %v, want exactly [%s]", k.asked, tc.wantRef)
}
}
})
}
}
// TestForgeSecretRefIsOrgScoped pins the coordinate. It is injective in org, so
// one tenant's credential is never addressable as another's, and it is disjoint
// from the namespaces a tenant's own app secrets live in — otherwise an app could
// seal over the credential that pushes its code.
func TestForgeSecretRefIsOrgScoped(t *testing.T) {
for _, tc := range []struct{ a, b string }{
{"acme", "acme2"},
{"acme", "acme-2"},
{"a", "b"},
{"acme", "acme/x"},
} {
if forgeSecretRef(tc.a) == forgeSecretRef(tc.b) {
t.Fatalf("orgs %q and %q collide on %s", tc.a, tc.b, forgeSecretRef(tc.a))
}
}
got := forgeSecretRef("acme")
if want := "orgs/acme/forge/push"; got != want {
t.Fatalf("forgeSecretRef = %q, want %q", got, want)
}
for _, other := range []string{"orgs/acme/platform/", "orgs/acme/kms-auth/"} {
if strings.HasPrefix(got, other) {
t.Fatalf("%s collides with the %s namespace", got, other)
}
}
}
func TestDestinationNamesAreChecked(t *testing.T) {
for _, tc := range []struct {
name string
repo string
ok bool
check func(string) error
}{
{"a plain repo name", "widgets", true, checkRepoName},
{"dots and dashes are fine", "my-app.v2_x", true, checkRepoName},
{"a slash would change the owner", "other-org/widgets", false, checkRepoName},
{"traversal is refused", "..", false, checkRepoName},
{"a leading dot is refused", ".git", false, checkRepoName},
{"a url is refused", "https://evil.example/x", false, checkRepoName},
{"a space is refused", "my repo", false, checkRepoName},
{"a plain branch", "main", true, checkBranch},
{"a namespaced branch", "agent/fix-123", true, checkBranch},
{"traversal is refused", "a..b", false, checkBranch},
{"a leading dash is refused", "-force", false, checkBranch},
{"a trailing slash is refused", "feature/", false, checkBranch},
{"a lock suffix is refused", "main.lock", false, checkBranch},
{"a caret is refused", "main^", false, checkBranch},
{"a space is refused", "my branch", false, checkBranch},
{"empty is refused", "", false, checkBranch},
} {
t.Run(tc.name+"/"+tc.repo, func(t *testing.T) {
if err := tc.check(tc.repo); tc.ok != (err == nil) {
t.Fatalf("ok=%v, err=%v", err == nil, err)
}
})
}
}
// ── the whole thing, for real ──────────────────────────────────────────────────
// TestPushLandsRealCommitsOnARealForge runs the production path end to end with
// real git on both sides: a real repository stands in for the sandbox, packOut
// builds a real packfile with real plumbing, and it lands in a real bare repo
// through a real git-http-backend that refuses anyone without the credential.
//
// It asserts the AUTHOR and MESSAGE survive, because that is the difference
// between moving the sandbox's commits and rebuilding a new commit from its files
// — which is what the file-list push this replaces would have done.
func TestPushLandsRealCommitsOnARealForge(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
const user, token = "acme-bot", "seal3d-in-kms"
root := t.TempDir()
bare := filepath.Join(root, "acme", "widgets.git")
sh(t, "", "git", "init", "-q", "--bare", "-b", "main", bare)
// git-http-backend only serves a push when the repo says it may.
sh(t, bare, "git", "config", "http.receivepack", "true")
base := serveForge(t, root, user, token)
work := filepath.Join(root, "sandbox")
if err := os.MkdirAll(work, 0o755); err != nil {
t.Fatal(err)
}
sh(t, work, "git", "init", "-q", "-b", "main")
sh(t, work, "git", "config", "user.email", "agent@acme.test")
sh(t, work, "git", "config", "user.name", "Acme Agent")
write(t, work, "README.md", "hello from the sandbox\n")
sh(t, work, "git", "add", "-A")
sh(t, work, "git", "commit", "-q", "-m", "the sandbox wrote this")
first := strings.TrimSpace(out(t, work, "git", "rev-parse", "HEAD"))
cred := forgeCred{Host: base, Owner: "acme", User: user, Token: token}
run := localRunner(t)
ctx := context.Background()
// 1. The first push creates the branch on a forge that had nothing.
got, err := pushTo(ctx, run, cred, PushSpec{Repo: "widgets", Branch: "main", Dir: work})
if err != nil {
t.Fatalf("first push: %v", err)
}
if got.Commit != first {
t.Fatalf("pushed %s, want %s", got.Commit, first)
}
if got.Previous != "" || got.UpToDate {
t.Fatalf("a first push should have no previous and not be up to date: %+v", got)
}
if landed := strings.TrimSpace(out(t, bare, "git", "rev-parse", "refs/heads/main")); landed != first {
t.Fatalf("forge is at %s, want %s", landed, first)
}
// The commit is the SANDBOX's commit, not one rebuilt here.
if meta := strings.TrimSpace(out(t, bare, "git", "log", "-1", "--format=%an|%ae|%s", "main")); meta !=
"Acme Agent|agent@acme.test|the sandbox wrote this" {
t.Fatalf("the forge holds %q — the commit was not carried across intact", meta)
}
// 2. Pushing again with nothing new moves nothing and says so.
again, err := pushTo(ctx, run, cred, PushSpec{Repo: "widgets", Branch: "main", Dir: work})
if err != nil {
t.Fatalf("second push: %v", err)
}
if !again.UpToDate {
t.Fatalf("re-pushing the same tip should be up to date: %+v", again)
}
// 3. A second commit transfers INCREMENTALLY — only what the forge lacked.
write(t, work, "second.txt", strings.Repeat("padding so the full history is clearly bigger\n", 400))
sh(t, work, "git", "add", "-A")
sh(t, work, "git", "commit", "-q", "-m", "second")
second := strings.TrimSpace(out(t, work, "git", "rev-parse", "HEAD"))
inc, err := pushTo(ctx, run, cred, PushSpec{Repo: "widgets", Branch: "main", Dir: work})
if err != nil {
t.Fatalf("incremental push: %v", err)
}
if inc.Commit != second || inc.Previous != first {
t.Fatalf("incremental push reported %+v, want %s over %s", inc, second, first)
}
if landed := strings.TrimSpace(out(t, bare, "git", "rev-parse", "refs/heads/main")); landed != second {
t.Fatalf("forge is at %s, want %s", landed, second)
}
// 4. A rewritten history is REFUSED unless the caller says force. Without the
// check this level of the protocol would have accepted it silently.
sh(t, work, "git", "reset", "-q", "--hard", first)
write(t, work, "divergent.txt", "a different second commit\n")
sh(t, work, "git", "add", "-A")
sh(t, work, "git", "commit", "-q", "-m", "divergent")
div := strings.TrimSpace(out(t, work, "git", "rev-parse", "HEAD"))
if _, err := pushTo(ctx, run, cred, PushSpec{Repo: "widgets", Branch: "main", Dir: work}); err == nil {
t.Fatal("a non-fast-forward push was accepted without force")
} else if !strings.Contains(err.Error(), "not an ancestor") {
t.Fatalf("wanted a fast-forward refusal, got %v", err)
}
if landed := strings.TrimSpace(out(t, bare, "git", "rev-parse", "refs/heads/main")); landed != second {
t.Fatalf("the refused push still moved the forge to %s", landed)
}
forced, err := pushTo(ctx, run, cred, PushSpec{Repo: "widgets", Branch: "main", Dir: work, Force: true})
if err != nil {
t.Fatalf("forced push: %v", err)
}
if forced.Commit != div {
t.Fatalf("forced push landed %s, want %s", forced.Commit, div)
}
// 5. An agent's work lands on ITS OWN branch when the caller names one.
onBranch, err := pushTo(ctx, run, cred, PushSpec{
Repo: "widgets", Branch: "main", RemoteBranch: "agent/proposal", Dir: work})
if err != nil {
t.Fatalf("branch push: %v", err)
}
if onBranch.Branch != "agent/proposal" {
t.Fatalf("landed on %q", onBranch.Branch)
}
if landed := strings.TrimSpace(out(t, bare, "git", "rev-parse", "refs/heads/agent/proposal")); landed != div {
t.Fatalf("agent/proposal is at %s, want %s", landed, div)
}
}
// TestPushRefusesAForgeThatRefusesTheCredential proves the credential is really
// being presented and really being checked — a push with the wrong token fails
// rather than landing anonymously.
func TestPushRefusesAForgeThatRefusesTheCredential(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
root := t.TempDir()
bare := filepath.Join(root, "acme", "widgets.git")
sh(t, "", "git", "init", "-q", "--bare", "-b", "main", bare)
sh(t, bare, "git", "config", "http.receivepack", "true")
base := serveForge(t, root, "acme-bot", "the-real-token")
work := filepath.Join(root, "sandbox")
if err := os.MkdirAll(work, 0o755); err != nil {
t.Fatal(err)
}
sh(t, work, "git", "init", "-q", "-b", "main")
sh(t, work, "git", "config", "user.email", "a@b.c")
sh(t, work, "git", "config", "user.name", "a")
write(t, work, "f", "x\n")
sh(t, work, "git", "add", "-A")
sh(t, work, "git", "commit", "-q", "-m", "c")
cred := forgeCred{Host: base, Owner: "acme", User: "acme-bot", Token: "the-wrong-token"}
_, err := pushTo(context.Background(), localRunner(t), cred,
PushSpec{Repo: "widgets", Branch: "main", Dir: work})
if err == nil {
t.Fatal("a push with the wrong token was accepted")
}
t.Logf("refused with: %v", err)
// --verify -q prints NOTHING when the ref does not resolve; plain rev-parse
// echoes the ref back, which is not the same as the branch existing.
if got := strings.TrimSpace(outAllowFail(t, bare, "git", "rev-parse", "--verify", "-q", "refs/heads/main")); got != "" {
t.Fatalf("the forge advanced to %s despite refusing the credential", got)
}
}
// ── test plumbing ──────────────────────────────────────────────────────────────
// localRunner runs the sandbox-side script HERE. It is the pod's part of the
// protocol, played by the machine running the test.
func localRunner(t *testing.T) runner {
t.Helper()
return func(ctx context.Context, argv []string, _ io.Reader) (ExecResult, error) {
c := exec.CommandContext(ctx, argv[0], argv[1:]...)
c.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
"GIT_TERMINAL_PROMPT=0")
var stdout, stderr strings.Builder
c.Stdout, c.Stderr = &stdout, &stderr
err := c.Run()
res := ExecResult{Stdout: stdout.String(), Stderr: stderr.String()}
var ee *exec.ExitError
if errors.As(err, &ee) {
res.ExitCode = ee.ExitCode()
return res, nil
}
return res, err
}
}
// serveForge is a real git smart-HTTP server that demands basic auth, so a push
// that forgets the credential fails the way the real forge makes it fail.
func serveForge(t *testing.T, root, user, token string) string {
t.Helper()
execPath := strings.TrimSpace(out(t, "", "git", "--exec-path"))
backend := filepath.Join(execPath, "git-http-backend")
if _, err := os.Stat(backend); err != nil {
t.Skipf("git-http-backend is not available: %v", err)
}
h := &cgi.Handler{
Path: backend,
Env: []string{
"GIT_PROJECT_ROOT=" + root,
"GIT_HTTP_EXPORT_ALL=1",
// http-backend serves a push only for an authenticated user.
"REMOTE_USER=" + user,
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || u != user || p != token {
w.Header().Set("WWW-Authenticate", `Basic realm="git"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
return srv.URL
}
// sha256hex is what the pod reports about the file it just wrote.
func sha256hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// chunkOffsetOf recovers the byte offset a `dd ... skip=N` command is asking for,
// so the fake answers the same chunk the real pod would.
func chunkOffsetOf(cmd string) int {
_, rest, ok := strings.Cut(cmd, "skip=")
if !ok {
return 0
}
n, _, _ := strings.Cut(rest, " ")
blocks, err := strconv.Atoi(n)
if err != nil {
return 0
}
return blocks * chunkBytes
}
func sh(t *testing.T, dir string, args ...string) {
t.Helper()
c := exec.Command(args[0], args[1:]...)
c.Dir = dir
c.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
if b, err := c.CombinedOutput(); err != nil {
t.Fatalf("%s: %v\n%s", strings.Join(args, " "), err, b)
}
}
func out(t *testing.T, dir string, args ...string) string {
t.Helper()
c := exec.Command(args[0], args[1:]...)
c.Dir = dir
c.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
b, err := c.Output()
if err != nil {
t.Fatalf("%s: %v", strings.Join(args, " "), err)
}
return string(b)
}
func outAllowFail(t *testing.T, dir string, args ...string) string {
t.Helper()
c := exec.Command(args[0], args[1:]...)
c.Dir = dir
b, _ := c.Output()
return string(b)
}
func write(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
+2
View File
@@ -15,6 +15,7 @@
// POST /v1/sandboxes/:id/exec {argv|command, stdin?, timeoutSec?} -> {exitCode,stdout,stderr}
// GET /v1/sandboxes/:id/fs ?path= read a file, or list a directory
// POST /v1/sandboxes/:id/fs ?path= write a file
// POST /v1/sandboxes/:id/push {repo, branch?, remoteBranch?, dir?} -> {commit,...}
//
// THERE IS EXACTLY ONE WAY INTO A SANDBOX, and it is the Kubernetes exec
// subresource. fs read/list/write are not a second channel — they are `cat`,
@@ -165,6 +166,7 @@ func Routes(app cloud.Router, s *cloud.Service[state]) {
g.Post("/:id/exec", cloud.Handle(s, ExecIn))
g.Get("/:id/fs", cloud.Handle(s, FsRead))
g.Post("/:id/fs", cloud.Handle(s, FsWrite))
g.Post("/:id/push", cloud.Handle(s, Push))
}
func orgOf(c *zip.Ctx) (string, bool) { return principal.Org(c) }