Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f3d2b6a53 | ||
|
|
9a0c4a8f1b |
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user