Red returned do-not-ship on two CRITICALs that chain to unauthenticated
cross-tenant code execution. Both are proven by apps/exec/auth_test.go, and all
four assertions FAIL when the old behaviour is put back — mutation-checked, not
asserted.
1. THE TENANT CAME OFF A HEADER. callCtx preferred cloud.Who(ctx).Org, which is
zip.CallerOf, which reads the X-Org-Id REQUEST HEADER (zip caller.go:377) —
and for a request with no validated bearer, SanitizeIdentity deliberately
RESTORES the client's own header (middleware_identity.go:455). So
`X-Org-Id: victim-corp` made storeFor open the victim's SQLite file: the run
executed in their store and /v1/files + /v1/download read their artifacts
back out. principal.OrgFrom is the org a VALIDATED principal resolved to and
nothing else; every other app resolves through it and this one did not.
plane.go's own note — "an org in the argument is an org the caller chose" —
is the rule it was breaking.
2. THE CREDENTIAL CHECK WAS A LOWERCASE PREFIX LIST OVER c.Path(). fiber routes
case-insensitively; cloud.RoutePath exists in this repo for exactly that and
two other gates already use it. POST /V1/EXEC matched the route and missed
the list: no key at all ran code, a wrong key read another session's bytes,
and CODE_EXEC_API_KEY UNSET — the documented fail-closed 503 — still ran code.
Same root, so one fix: authorization stops being inferred from the SPELLING of a
request. The middleware normalizes with RoutePath AND parks two facts on the
context — principal.WithOrg (inherited by the typed op, typed.go:82) and an
unexported `admitted` marker. tenantOf is the ONE tenant decision and refuses a
context carrying neither. The prefix list may still drift; drift is now
fail-CLOSED — a missed path is a 403 on a route that should have worked, never a
route that works without a credential.
AND THE DOOR NO LIST COULD HAVE COVERED. A typed op is also an MCP tool and an
op-plane op, and tools/call invokes it DIRECTLY (zip typed.go:474) — no route, no
middleware. `POST /mcp name=post_v1_exec` with no key ran code. Typing /v1/exec
for its SDK value is what opened that door; the handler-side check closes it,
because those doors park no marker and carry no principal.
3. [HIGH] AN EXEC SANDBOX HAD NO CEILING. Single-attach bounds dev/desktop via
their project; an exec sandbox has none, and the code tool sends no
session_id, so every call mints a fresh pod on a 15-minute lease — 40 calls,
40 pods, each 250m/512Mi/2Gi. The reaper is the FLOOR, not the ceiling: it
ends leases that are over, bounding the steady state and never the burst.
maxLiveExec=16 is written in node capacity (8Gi, 4 cores) and refuses with
429, because the caller's correct response is to wait. Counted with a real
COUNT(*), not len(List) — List is LIMIT 200, which stops counting exactly
where refusing starts to matter.
4. [HIGH] TWO CLIENT-SHAPE BUGS, both silent. hanzo.chat primes an attachment as
{id, session_id, name} (Files/Code/process.js) while @hanzochat/agents spells
it storage_session_id (tools.d.ts) — reading only the second skipped every
attached file AND the "not available" note, so a user's CSV was invisible with
no error. CodeFile.Session() reads both, and a ref with neither is reported.
Artifacts were COLLECTED recursively (find) and LISTED top-level (ls -1A), so
a nested artifact appeared in the reply and was missing from /v1/files/{sid},
which the client reads as expired. One find answers both now.
Also: apps/functions.go's doc no longer cites the deleted CODE_EXEC_UPSTREAM.
Tests: 22 in apps/exec (14 + 5 auth + 3 client-shape), 6 in apps/sandbox. Full
suite 3 red — base, code, commerce — all pre-existing and environmental (this
box's SQLite lacks fts5 and acos). Zero new failures. make check green.
NOT FIXED, and it is a decision rather than a defect: the real chat client sends
no credential at all (EnvVar.CODE_API_KEY undefined, handleTools.js sends no auth
header, the chat pod has no LIBRECHAT_CODE_API_KEY), so the guard 401s every
legitimate request. Fixing the bypasses does not make the feature work; settling
what the client presents does, and the house rule says Hanzo IAM rather than a
second shared secret.
476 lines
19 KiB
Go
476 lines
19 KiB
Go
package exec
|
|
|
|
// exec_test.go — the code-interpreter contract, measured end to end.
|
|
//
|
|
// Every assertion here is a fact the CALLERS depend on, taken from what they
|
|
// actually do: @hanzochat/agents CodeExecutor for POST /exec, and hanzo.chat's
|
|
// api/server/services/Files/Code for upload, download and the session listing. The
|
|
// wire is theirs, so the tests are about their shapes and not about ours.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
luxlog "github.com/luxfi/log"
|
|
|
|
"github.com/hanzoai/cloud"
|
|
"github.com/hanzoai/cloud/internal/planetest"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// servePeer is the shared sandboxes peer (internal/planetest): the five ops
|
|
// apps/sandbox publishes, on a real socket, with a map where the pod would be. Every
|
|
// assertion below therefore goes through the actual composition — cloud.Ask resolves
|
|
// the app, zip dispatches the op, the reply decodes into the declared type — so an
|
|
// op renamed or a field moved fails here rather than in production.
|
|
func servePeer(t *testing.T) *planetest.Sandboxes { return planetest.ServeSandboxes(t) }
|
|
|
|
func mount(t *testing.T) *zip.App {
|
|
t.Helper()
|
|
t.Setenv("CODE_EXEC_API_KEY", "k")
|
|
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
|
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo"}); err != nil {
|
|
t.Fatalf("Mount: %v", err)
|
|
}
|
|
return app
|
|
}
|
|
|
|
func call(t *testing.T, app *zip.App, method, path, ctype string, body io.Reader) *http.Response {
|
|
t.Helper()
|
|
rq := httptest.NewRequest(method, "http://api.hanzo.ai"+path, body)
|
|
rq.Header.Set("X-API-Key", "k")
|
|
if ctype != "" {
|
|
rq.Header.Set("Content-Type", ctype)
|
|
}
|
|
resp, err := app.Test(rq, zip.TestConfig{Timeout: 30 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
t.Cleanup(func() { _ = resp.Body.Close() })
|
|
return resp
|
|
}
|
|
|
|
func post(t *testing.T, app *zip.App, path string, v any) *http.Response {
|
|
t.Helper()
|
|
b, _ := json.Marshal(v)
|
|
return call(t, app, http.MethodPost, path, "application/json", bytes.NewReader(b))
|
|
}
|
|
|
|
func decode[T any](t *testing.T, resp *http.Response) T {
|
|
t.Helper()
|
|
var out T
|
|
b, _ := io.ReadAll(resp.Body)
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
t.Fatalf("decode %q: %v", b, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestExecRunsInASandboxAndAnswersTheContract is the whole subsystem in one call:
|
|
// a snippet goes in, the program runs in a leased sandbox, and what comes back is
|
|
// the four fields the CodeExecutor tool reads — session_id, stdout, stderr, files.
|
|
func TestExecRunsInASandboxAndAnswersTheContract(t *testing.T) {
|
|
p := servePeer(t)
|
|
p.Run = func(id string, argv []string) (string, string, int, map[string][]byte) {
|
|
return "hello\n", "", 0, map[string][]byte{"plot.png": []byte("\x89PNG")}
|
|
}
|
|
app := mount(t)
|
|
|
|
res := decode[CodeResult](t, post(t, app, Path, CodeRun{Lang: "py", Code: "print('hello')"}))
|
|
if res.SessionID == "" {
|
|
t.Fatal("no session_id — the client keys every later upload, download and listing on it")
|
|
}
|
|
if res.Stdout != "hello\n" || res.Stderr != "" {
|
|
t.Errorf("stdout/stderr = %q/%q, want the program's own output", res.Stdout, res.Stderr)
|
|
}
|
|
if len(res.Files) != 1 || res.Files[0].ID != "plot.png" || res.Files[0].Name != "plot.png" {
|
|
t.Fatalf("files = %+v, want the one artifact the run wrote", res.Files)
|
|
}
|
|
|
|
// The program was written into the session before it ran, under the name the
|
|
// language table gives it — the sandbox is where the code IS, not just where it
|
|
// executes.
|
|
if pod := p.Pod(res.SessionID); pod == nil {
|
|
t.Fatal("no sandbox was leased")
|
|
} else if string(pod.Files["main.py"]) != "print('hello')" {
|
|
t.Errorf("main.py = %q, want the submitted code", pod.Files["main.py"])
|
|
}
|
|
}
|
|
|
|
// TestTheSourceFileIsNotReportedAsAnArtifact is the reason the marker is stamped
|
|
// INSIDE the run command rather than before it. Write the program, stamp, run: the
|
|
// program is older than the mark, so the sweep does not report the code back to the
|
|
// caller as a file its own run produced.
|
|
func TestTheSourceFileIsNotReportedAsAnArtifact(t *testing.T) {
|
|
p := servePeer(t)
|
|
p.Run = func(string, []string) (string, string, int, map[string][]byte) {
|
|
return "", "", 0, nil
|
|
}
|
|
app := mount(t)
|
|
res := decode[CodeResult](t, post(t, app, Path, CodeRun{Lang: "py", Code: "pass"}))
|
|
for _, f := range res.Files {
|
|
if f.ID == "main.py" {
|
|
t.Fatalf("the source file came back as an artifact: %+v", res.Files)
|
|
}
|
|
}
|
|
if len(res.Files) != 0 {
|
|
t.Errorf("files = %+v, want none — this run wrote nothing", res.Files)
|
|
}
|
|
// And the ordering that makes it true is visible: the marker line is the run,
|
|
// and the sweep comes after it.
|
|
lines := p.Lines()
|
|
if len(lines) < 2 || !strings.HasPrefix(lines[0], ": > "+marker) ||
|
|
!strings.Contains(lines[1], "-newer "+marker) {
|
|
t.Errorf("ran %v, want the marker+program line then the -newer sweep", lines)
|
|
}
|
|
}
|
|
|
|
// TestSessionIsResumed: the session_id the client sends back names the SAME sandbox,
|
|
// which is what makes a conversation's files still be there on the next turn.
|
|
func TestSessionIsResumed(t *testing.T) {
|
|
p := servePeer(t)
|
|
p.Run = func(string, []string) (string, string, int, map[string][]byte) { return "1", "", 0, nil }
|
|
app := mount(t)
|
|
|
|
first := decode[CodeResult](t, post(t, app, Path, CodeRun{Lang: "py", Code: "x=1"}))
|
|
second := decode[CodeResult](t, post(t, app, Path,
|
|
CodeRun{Lang: "py", Code: "x=2", SessionID: first.SessionID}))
|
|
if second.SessionID != first.SessionID {
|
|
t.Fatalf("session %q became %q — a resumed session must be the same sandbox",
|
|
first.SessionID, second.SessionID)
|
|
}
|
|
}
|
|
|
|
// TestArgsReachTheProgramAndNotTheCompiler pins the one thing the language table
|
|
// would get wrong if it appended `"$@"` instead of placing it: for a compiled
|
|
// language the arguments belong to the produced binary, not to the compiler.
|
|
func TestArgsReachTheProgramAndNotTheCompiler(t *testing.T) {
|
|
p := servePeer(t)
|
|
p.Run = func(string, []string) (string, string, int, map[string][]byte) { return "", "", 0, nil }
|
|
app := mount(t)
|
|
|
|
post(t, app, Path, CodeRun{Lang: "c", Code: "int main(){}", Args: []string{"alpha", "beta"}})
|
|
if got := p.Args(); len(got) != 2 || got[0] != "alpha" || got[1] != "beta" {
|
|
t.Fatalf("args reached the shell as %v, want [alpha beta]", got)
|
|
}
|
|
line := p.Lines()[0]
|
|
if !strings.Contains(line, `./main "$@"`) {
|
|
t.Errorf("c runs %q — the arguments must be applied to ./main, never to cc", line)
|
|
}
|
|
}
|
|
|
|
// TestUnsupportedLanguageIsRefused: the tool schema advertises a closed set, so a
|
|
// lang outside it is a 400 that names the set — not a shell line that fails later
|
|
// with a message about a missing binary.
|
|
func TestUnsupportedLanguageIsRefused(t *testing.T) {
|
|
servePeer(t)
|
|
app := mount(t)
|
|
resp := post(t, app, Path, CodeRun{Lang: "brainfuck", Code: "+"})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
if b, _ := io.ReadAll(resp.Body); !strings.Contains(string(b), "py") {
|
|
t.Errorf("body %q does not name the supported set", b)
|
|
}
|
|
}
|
|
|
|
// TestNonZeroExitIsA200: "the code threw" and "the interpreter is down" are
|
|
// different facts. The tool renders stderr; it never sees a 5xx for a program that
|
|
// merely failed.
|
|
func TestNonZeroExitIsA200(t *testing.T) {
|
|
p := servePeer(t)
|
|
p.Run = func(string, []string) (string, string, int, map[string][]byte) {
|
|
return "", "Traceback...\nZeroDivisionError\n", 1, nil
|
|
}
|
|
app := mount(t)
|
|
resp := post(t, app, Path, CodeRun{Lang: "py", Code: "1/0"})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 — a failed program is a successful call", resp.StatusCode)
|
|
}
|
|
if res := decode[CodeResult](t, resp); !strings.Contains(res.Stderr, "ZeroDivisionError") {
|
|
t.Errorf("stderr = %q, want the program's traceback", res.Stderr)
|
|
}
|
|
}
|
|
|
|
// ---- the file surface ------------------------------------------------------
|
|
|
|
func uploadFile(t *testing.T, app *zip.App, session, name, content string) *http.Response {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
w := multipart.NewWriter(&body)
|
|
if session != "" {
|
|
_ = w.WriteField("session_id", session)
|
|
}
|
|
fw, err := w.CreateFormFile("file", name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = fw.Write([]byte(content))
|
|
_ = w.Close()
|
|
return call(t, app, http.MethodPost, "/v1/upload", w.FormDataContentType(), &body)
|
|
}
|
|
|
|
// TestUploadAnswersTheShapeTheClientChecks. crud.js reads `message` FIRST and
|
|
// throws unless it is the literal "success", then builds `${session_id}/${fileId}`.
|
|
// Both facts are load-bearing and neither is inferable from the other.
|
|
func TestUploadAnswersTheShapeTheClientChecks(t *testing.T) {
|
|
servePeer(t)
|
|
app := mount(t)
|
|
res := decode[uploaded](t, uploadFile(t, app, "", "data.csv", "id,v\n1,2\n"))
|
|
if res.Message != "success" {
|
|
t.Errorf("message = %q, want the literal \"success\" — the client throws on anything else", res.Message)
|
|
}
|
|
if res.SessionID == "" || len(res.Files) != 1 || res.Files[0].FileID == "" ||
|
|
res.Files[0].Filename != "data.csv" {
|
|
t.Fatalf("upload answered %+v, want a session and one {fileId, filename}", res)
|
|
}
|
|
}
|
|
|
|
// TestUploadThenExecSeesTheFile is the whole reason a session is a sandbox: the
|
|
// bytes are already where the next run will look for them, with nothing copied.
|
|
func TestUploadThenExecSeesTheFile(t *testing.T) {
|
|
p := servePeer(t)
|
|
app := mount(t)
|
|
up := decode[uploaded](t, uploadFile(t, app, "", "data.csv", "id,v\n1,2\n"))
|
|
|
|
p.Run = func(string, []string) (string, string, int, map[string][]byte) { return "read\n", "", 0, nil }
|
|
res := decode[CodeResult](t, post(t, app, Path,
|
|
CodeRun{Lang: "py", Code: "open('data.csv')", SessionID: up.SessionID}))
|
|
if res.SessionID != up.SessionID {
|
|
t.Fatalf("exec ran in %q, not the uploaded session %q", res.SessionID, up.SessionID)
|
|
}
|
|
if got := string(p.Pod(res.SessionID).Files["data.csv"]); got != "id,v\n1,2\n" {
|
|
t.Errorf("data.csv in the run's sandbox = %q, want the uploaded bytes", got)
|
|
}
|
|
}
|
|
|
|
// TestDownloadIsTwoSegmentsAndAnswersBytes. The path is {session_id}/{fileId}
|
|
// (crud.js getCodeOutputDownloadStream, process.js), and the body is the artifact
|
|
// itself — not JSON, and not a base64 field.
|
|
func TestDownloadIsTwoSegmentsAndAnswersBytes(t *testing.T) {
|
|
p := servePeer(t)
|
|
p.Run = func(string, []string) (string, string, int, map[string][]byte) {
|
|
return "", "", 0, map[string][]byte{"plot.png": []byte("\x89PNG\r\n\x1a\n")}
|
|
}
|
|
app := mount(t)
|
|
res := decode[CodeResult](t, post(t, app, Path, CodeRun{Lang: "py", Code: "savefig"}))
|
|
|
|
resp := call(t, app, http.MethodGet, "/v1/download/"+res.SessionID+"/"+res.Files[0].ID, "", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if b, _ := io.ReadAll(resp.Body); string(b) != "\x89PNG\r\n\x1a\n" {
|
|
t.Errorf("body = %q, want the artifact's bytes verbatim", b)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/png") {
|
|
t.Errorf("Content-Type = %q, want it derived from the name", ct)
|
|
}
|
|
// A one-segment path is not this contract and must not be guessed at.
|
|
if r := call(t, app, http.MethodGet, "/v1/download/"+res.Files[0].ID, "", nil); r.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("one-segment download = %d, want 400", r.StatusCode)
|
|
}
|
|
}
|
|
|
|
// TestFilesAnswersABareArrayKeyedByTheDownloadIdentifier. getSessionInfo does
|
|
// `response.data.find((f) => f.name.startsWith(path))` where `path` is
|
|
// "{session}/{fileId}" — so the body is an ARRAY and `name` is that whole
|
|
// identifier, not the bare filename. An object wrapper or a short name breaks it
|
|
// silently: `.find` returns undefined and the caller reads it as "no such file".
|
|
func TestFilesAnswersABareArrayKeyedByTheDownloadIdentifier(t *testing.T) {
|
|
servePeer(t)
|
|
app := mount(t)
|
|
up := decode[uploaded](t, uploadFile(t, app, "", "data.csv", "x"))
|
|
|
|
resp := call(t, app, http.MethodGet, "/v1/files/"+up.SessionID, "", nil)
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
|
|
t.Fatalf("body = %s, want a BARE JSON array", raw)
|
|
}
|
|
var rows []listing
|
|
if err := json.Unmarshal(raw, &rows); err != nil {
|
|
t.Fatalf("decode %s: %v", raw, err)
|
|
}
|
|
want := up.SessionID + "/data.csv"
|
|
for _, r := range rows {
|
|
if r.Name == want {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("listing %+v has no row named %q — the client matches on that prefix", rows, want)
|
|
}
|
|
|
|
// ---- auth ------------------------------------------------------------------
|
|
|
|
// TestUnsetKeyFailsClosed: a deployment with no credential configured is 503 on
|
|
// every path, never open.
|
|
func TestUnsetKeyFailsClosed(t *testing.T) {
|
|
servePeer(t)
|
|
app := mount(t)
|
|
t.Setenv("CODE_EXEC_API_KEY", "")
|
|
for _, p := range []string{Path, "/v1/upload", "/v1/download/s/f", "/v1/files/s"} {
|
|
resp := call(t, app, http.MethodGet, p, "", nil)
|
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
|
t.Errorf("%s with no key = %d, want 503", p, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWrongKeyIsRejectedOnEveryPath, including the TYPED op — which is the reason
|
|
// the credential check is middleware and not a handler wrapper: a typed
|
|
// registration takes no handler chain, so a per-route wrap could not have covered
|
|
// POST /v1/exec at all.
|
|
func TestWrongKeyIsRejectedOnEveryPath(t *testing.T) {
|
|
p := servePeer(t)
|
|
p.Run = func(string, []string) (string, string, int, map[string][]byte) { return "ran", "", 0, nil }
|
|
app := mount(t)
|
|
for _, path := range []string{Path, "/v1/upload", "/v1/download/s/f", "/v1/files/s"} {
|
|
rq := httptest.NewRequest(http.MethodPost, "http://api.hanzo.ai"+path,
|
|
strings.NewReader(`{"lang":"py","code":"x=1"}`))
|
|
rq.Header.Set("X-API-Key", "wrong")
|
|
rq.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(rq, zip.TestConfig{Timeout: 30 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", path, err)
|
|
}
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("%s with a wrong key = %d, want 401", path, resp.StatusCode)
|
|
}
|
|
_ = resp.Body.Close()
|
|
}
|
|
if len(p.Lines()) != 0 {
|
|
t.Errorf("a rejected request still ran %v in a sandbox", p.Lines())
|
|
}
|
|
}
|
|
|
|
// TestMountRejectsBadInputs.
|
|
func TestMountRejectsBadInputs(t *testing.T) {
|
|
if err := Mount(nil, cloud.Deps{Logger: luxlog.New("test")}); err == nil {
|
|
t.Fatal("Mount(nil app) should error")
|
|
}
|
|
if err := Mount(zip.New(zip.Config{Logger: luxlog.New("test")}), cloud.Deps{}); err == nil {
|
|
t.Fatal("Mount(nil logger) should error")
|
|
}
|
|
}
|
|
|
|
// TestProgrammaticRefusesInTheOpen. /exec/programmatic is a different protocol —
|
|
// a run suspended on each tool call and resumed from a continuation token — so it
|
|
// answers 501 rather than being routed into the plain interpreter, which would hand
|
|
// the caller a body its parser cannot read.
|
|
func TestProgrammaticRefusesInTheOpen(t *testing.T) {
|
|
servePeer(t)
|
|
app := mount(t)
|
|
resp := post(t, app, Path+"/programmatic", map[string]any{"code": "x=1"})
|
|
if resp.StatusCode != http.StatusNotImplemented {
|
|
t.Fatalf("status = %d, want 501", resp.StatusCode)
|
|
}
|
|
if b, _ := io.ReadAll(resp.Body); !strings.Contains(string(b), "continuation token") {
|
|
t.Errorf("body %q does not say what would be needed to serve it", b)
|
|
}
|
|
}
|
|
|
|
// TestAttachedFilesAreNotSilentlyDropped. hanzo.chat primes a user's attachment as
|
|
// {id, session_id, name} (Files/Code/process.js pushFile) while @hanzochat/agents
|
|
// spells the same field `storage_session_id` (tools.d.ts FileRef). Reading only the
|
|
// second meant every chat-attached file arrived with an empty session, was skipped
|
|
// by the copy loop, AND was skipped by the "not available" note — so a user's CSV
|
|
// was invisible to the program with nothing anywhere saying why.
|
|
func TestAttachedFilesAreNotSilentlyDropped(t *testing.T) {
|
|
p := servePeer(t)
|
|
p.Run = func(string, []string) (string, string, int, map[string][]byte) { return "", "", 0, nil }
|
|
app := mount(t)
|
|
|
|
// A file uploaded in one session, then attached to a run in another.
|
|
up := decode[uploaded](t, uploadFile(t, app, "", "data.csv", "id,v\n1,2\n"))
|
|
res := decode[CodeResult](t, post(t, app, Path, CodeRun{
|
|
Lang: "py", Code: "open('data.csv')",
|
|
// The chat's spelling, NOT the agents one.
|
|
Files: []CodeFile{{ID: "data.csv", Name: "data.csv", SessionID: up.SessionID}},
|
|
}))
|
|
got := p.Pod(res.SessionID)
|
|
if got == nil {
|
|
t.Fatal("no sandbox was leased")
|
|
}
|
|
if string(got.Files["data.csv"]) != "id,v\n1,2\n" {
|
|
t.Fatalf("the attached file did not reach the run's sandbox (files: %v) — `session_id` "+
|
|
"is the spelling hanzo.chat actually sends", sortedKeys(got.Files))
|
|
}
|
|
}
|
|
|
|
// TestAFileWithNoSessionSaysSo. The other half of the same silence: a ref naming
|
|
// bytes this deployment cannot find must be reported, not skipped.
|
|
func TestAFileWithNoSessionSaysSo(t *testing.T) {
|
|
p := servePeer(t)
|
|
p.Run = func(string, []string) (string, string, int, map[string][]byte) { return "", "", 0, nil }
|
|
app := mount(t)
|
|
|
|
res := decode[CodeResult](t, post(t, app, Path, CodeRun{
|
|
Lang: "py", Code: "pass",
|
|
Files: []CodeFile{{ID: "ghost.csv", Name: "ghost.csv"}}, // no session, either spelling
|
|
}))
|
|
if !strings.Contains(res.Stderr, "ghost.csv") {
|
|
t.Fatalf("stderr = %q, want it to name the input it could not provide — a run that "+
|
|
"silently cannot see its own input reads as a bug in the model's code", res.Stderr)
|
|
}
|
|
}
|
|
|
|
// TestNestedArtifactsAreListed. Artifacts were COLLECTED recursively (`find`) and
|
|
// LISTED top-level only (`ls -1A`), so a run that wrote out/plot.png reported it in
|
|
// the reply and then omitted it from the session listing — and the client's
|
|
// `name.startsWith(session/id)` found nothing and read the file as expired. Two
|
|
// traversals of one directory is two answers about what a session holds.
|
|
func TestNestedArtifactsAreListed(t *testing.T) {
|
|
p := servePeer(t)
|
|
p.Run = func(string, []string) (string, string, int, map[string][]byte) {
|
|
return "", "", 0, map[string][]byte{"out/plot.png": []byte("\x89PNG"), "top.csv": []byte("a,b")}
|
|
}
|
|
app := mount(t)
|
|
res := decode[CodeResult](t, post(t, app, Path, CodeRun{Lang: "py", Code: "savefig"}))
|
|
|
|
var reported []string
|
|
for _, f := range res.Files {
|
|
reported = append(reported, f.ID)
|
|
}
|
|
if len(reported) != 2 {
|
|
t.Fatalf("the run reported %v, want both the nested and the top-level artifact", reported)
|
|
}
|
|
|
|
resp := call(t, app, http.MethodGet, "/v1/files/"+res.SessionID, "", nil)
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
var rows []listing
|
|
if err := json.Unmarshal(raw, &rows); err != nil {
|
|
t.Fatalf("decode %s: %v", raw, err)
|
|
}
|
|
// EVERY id the run reported must be findable by the identifier the client
|
|
// downloads with, or that artifact reads as expired.
|
|
for _, id := range reported {
|
|
want := res.SessionID + "/" + id
|
|
found := false
|
|
for _, r := range rows {
|
|
if r.Name == want {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("the listing %+v has no row named %q — the run reported that artifact, so "+
|
|
"the client will ask for it and be told it is gone", rows, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func sortedKeys(m map[string][]byte) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|