diff --git a/config/dev.env.example b/config/dev.env.example index c9827f7..228ff76 100644 --- a/config/dev.env.example +++ b/config/dev.env.example @@ -60,4 +60,5 @@ DAYTONA_API_URL=https://app.daytona.io/api DAYTONA_TARGET=us DAYTONA_SNAPSHOT= DAYTONA_IMAGE=python:3.12-slim -DAYTONA_AUTO_PAUSE_MINUTES=15 +# Optional; only sandbox classes that support pausing accept this setting. +DAYTONA_AUTO_PAUSE_MINUTES= diff --git a/docs/architecture/storage-context-and-tools.md b/docs/architecture/storage-context-and-tools.md index ae066a9..d543199 100644 --- a/docs/architecture/storage-context-and-tools.md +++ b/docs/architecture/storage-context-and-tools.md @@ -265,8 +265,12 @@ For a large tool result: 2. when serialized output exceeds the documented 100,000-character threshold (about 25,000 tokens), create a bounded model projection containing a truncated preview, size, media type, and sandbox path; -3. create the documented public event projection; -4. record the sandbox path on the same durable tool step where applicable. +3. tell the Agent to inspect the exact saved file with bounded `bash` byte + slices; the line-oriented `read` tool is capped at 64 KiB inside every + sandbox provider and never downloads an arbitrarily large file into worker + memory; +4. create the documented public event projection; +5. record the sandbox path on the same durable tool step where applicable. If the sandbox disappears unexpectedly, the Session workspace has been lost and the runtime must surface that failure; it must not silently provision an empty diff --git a/docs/capabilities.md b/docs/capabilities.md index 37d5c18..cc7e6bb 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -42,7 +42,7 @@ and service test suites. | Events and client actions | Limited | System context, messages, thinking, tool events, confirmation/custom/self-hosted result barriers, outcomes, retries, interrupts, and the budget-boundary `session.usage`/`budget_reached` idle sequence are implemented. File-backed message documents are limited to bounded UTF-8 text; File-sourced images and File documents in tool results are not supported. | | Event streaming | Supported | PostgreSQL-authoritative Session and Thread streams with NATS wakeups, cursor repair, bounded backpressure, and opt-in ephemeral text previews. Streams do not replay history or interpret `Last-Event-ID`. | | Model and context runtime | Limited | Durable provider-native transcripts, Catwalk-derived model-window profiles with a conservative fallback, provider-usage anchors plus post-anchor estimates, predictive request admission, extractive and oversized-tool-result compaction, one-shot working-turn overflow recovery, and immutable per-Thread turn-preparation checkpoints are implemented. Explicit custom-endpoint overrides, provider-exact counters, complete per-provider-request audit records, later-round projection checkpoints, equivalent Outcome/Advisor overflow recovery, and compaction quality and retention evidence remain open. | -| Sandbox tools | Limited | `bash`, `read`, `write`, `edit`, `glob`, and `grep`, plus provider-native Web Search/Fetch. Local is development-only; Docker and the Preview remote providers expose separately admitted resource capabilities. | +| Sandbox tools | Limited | `bash`, `read`, `write`, `edit`, `glob`, and `grep`, plus provider-native Web Search/Fetch. `read` supports 1-based inclusive line ranges and is capped at 64 KiB inside the sandbox; larger files and persisted tool outputs use bounded `bash` byte slices. Local is development-only; Docker and the Preview remote providers expose separately admitted resource capabilities. | | MCP tools | Limited | Streamable HTTP discovery/execution, permissions, journaled calls, large-result materialization, and Vault bearer/OAuth authentication. Private-network connectivity, deprecated SSE, MCP resources, and prompts are not supported. | | [Files](api/files.md) | Limited | Configured S3-compatible storage, crash-recoverable intents, reusable snapshotted UTF-8 outcome rubrics, bounded UTF-8 File documents snapshotted into `user.message`, downloadable Session Resource copies, and Docker/E2B/Cube/OpenSandbox/Daytona publication of regular files beneath `/mnt/session/outputs` before idle. Client uploads are intentionally not downloadable. E2B/Cube currently buffer each output archive in worker memory. File-sourced images/PDFs and distributed reconciliation remain open. | | [Session Resources](api/session-resources.md) | Limited | Independent File copies, create-time Memory attachments, and create-time public HTTPS Git repository snapshots frozen to an exact commit. Runtime File attach/detach works. Git worktrees are writable on Docker/E2B/Cube/OpenSandbox/Daytona and restore offline from Mango storage. Private repository credentials, recursive submodules, LFS objects, repository Skill discovery, and runtime Git attach/detach remain open. Non-Docker Memory mounts are not supported. | diff --git a/docs/provenance.md b/docs/provenance.md index 6899987..07cc37e 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -33,6 +33,25 @@ release is never an automatic roadmap. SDK are optional research evidence; raw HTTP and OpenAPI tests define Mango's transport contract. +## Built-in Agent tools + +- The public Agent Toolset shapes and executable cases in the pinned Anthropic + Go SDK informed Mango's line-oriented `read.view_range` behavior: ranges are + 1-based and inclusive, and a non-positive end reads through EOF. +- Mango retains its existing `path`, `file_text`, `old_str`, and `new_str` + fields where they remain clear. The public SDK is design evidence, not a + field-for-field compatibility target or a runtime executor dependency. +- Mango does not advertise `bash.restart` because sandbox commands currently + execute independently and there is no persistent shell session to restart. + A future persistent-shell lifecycle must work through Mango's sandbox + abstraction before that capability can be exposed honestly. +- Mango caps each built-in `read` at 64 KiB inside the sandbox so untrusted + files cannot make worker memory scale without bound. Larger files and + persisted tool outputs use ordinary `bash` byte slicing (`dd`, `head`, + `tail`, or `sed`), following the established coding-agent split between a + line-oriented file viewer and a general shell rather than inventing a + Mango-specific character-pagination field. + ## Outbound Webhooks - The public [Claude Managed Agents Webhook guide](https://platform.claude.com/docs/en/managed-agents/webhooks), diff --git a/internal/agentruntime/tools/builtins.go b/internal/agentruntime/tools/builtins.go index 587a410..ccc981b 100644 --- a/internal/agentruntime/tools/builtins.go +++ b/internal/agentruntime/tools/builtins.go @@ -2,6 +2,9 @@ package tools import ( "context" + "encoding/json" + "fmt" + "math" "regexp" "sort" "strings" @@ -9,6 +12,8 @@ import ( "github.com/yanpgwang/mango/internal/sandbox" ) +const MaxReadFileBytes = 64 << 10 + // execBash runs a shell command inside the sandbox and returns its combined // stdout+stderr as text. A non-zero exit code is reported as a tool error. func execBash(ctx context.Context, sb sandbox.Sandbox, in map[string]any) Result { @@ -33,11 +38,92 @@ func execRead(ctx context.Context, sb sandbox.Sandbox, in map[string]any) Result if path == "" { return textResult("read: path is required", true) } - data, err := sb.ReadFile(ctx, path) + reader, ok := sb.(sandbox.BoundedFileReader) + if !ok { + return textResult("read: sandbox provider does not support bounded file reads", true) + } + data, truncated, err := reader.ReadFileBounded(ctx, path, MaxReadFileBytes) + if err != nil { + return textResult("read: "+err.Error(), true) + } + if truncated { + return textResult(fmt.Sprintf( + "read: file exceeds the %d-byte read limit; use bash with dd, head, tail, or sed to print a bounded slice", + MaxReadFileBytes, + ), true) + } + startLine, endLine, ranged, err := parseViewRange(in["view_range"]) if err != nil { return textResult("read: "+err.Error(), true) } - return textResult(string(data), false) + if !ranged { + return textResult(string(data), false) + } + lines := strings.Split(string(data), "\n") + start := 0 + if startLine > 1 { + start = startLine - 1 + } + if start >= len(lines) { + return textResult("", false) + } + end := len(lines) + if endLine > 0 && endLine < end { + end = endLine + } + if end < start { + return textResult("", false) + } + return textResult(strings.Join(lines[start:end], "\n"), false) +} + +func parseViewRange(raw any) (start, end int, present bool, err error) { + if raw == nil { + return 0, 0, false, nil + } + values, ok := raw.([]any) + if !ok || len(values) != 2 { + return 0, 0, true, fmt.Errorf("view_range must be [start_line, end_line]") + } + start, err = inputInteger(values[0]) + if err != nil { + return 0, 0, true, fmt.Errorf("view_range start_line: %w", err) + } + end, err = inputInteger(values[1]) + if err != nil { + return 0, 0, true, fmt.Errorf("view_range end_line: %w", err) + } + return start, end, true, nil +} + +func inputInteger(raw any) (int, error) { + switch value := raw.(type) { + case int: + return value, nil + case int64: + converted := int(value) + if int64(converted) != value { + return 0, fmt.Errorf("must fit in an integer") + } + return converted, nil + case json.Number: + value64, err := value.Int64() + if err != nil { + return 0, fmt.Errorf("must be an integer") + } + return inputInteger(value64) + case float64: + if math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value { + return 0, fmt.Errorf("must be an integer") + } + converted := int(value) + if float64(converted) != value { + return 0, fmt.Errorf("must fit in an integer") + } + return converted, nil + default: + return 0, fmt.Errorf("must be an integer") + } } // execWrite writes file_text to a file in the sandbox, creating or truncating. diff --git a/internal/agentruntime/tools/builtins_test.go b/internal/agentruntime/tools/builtins_test.go index df4819e..20ecae6 100644 --- a/internal/agentruntime/tools/builtins_test.go +++ b/internal/agentruntime/tools/builtins_test.go @@ -39,6 +39,67 @@ func TestBuiltins_WriteReadEditBash(t *testing.T) { } } +func TestBuiltins_ReadViewRange(t *testing.T) { + sb := newSB(t) + reg := Registry() + if r := reg["write"](context.Background(), sb, map[string]any{ + "path": "lines.txt", "file_text": "line1\nline2\nline3", + }); r.IsError { + t.Fatalf("write: %+v", r) + } + tests := []struct { + name string + rangeIn any + want string + wantErr bool + }{ + {name: "inclusive", rangeIn: []any{float64(2), float64(2)}, want: "line2"}, + {name: "through eof", rangeIn: []any{float64(2), float64(0)}, want: "line2\nline3"}, + {name: "inverted", rangeIn: []any{float64(3), float64(1)}, want: ""}, + {name: "past eof", rangeIn: []any{float64(10), float64(12)}, want: ""}, + {name: "wrong arity", rangeIn: []any{float64(2)}, wantErr: true}, + {name: "fractional", rangeIn: []any{1.5, float64(2)}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := reg["read"](context.Background(), sb, map[string]any{ + "path": "lines.txt", "view_range": tt.rangeIn, + }) + if r.IsError != tt.wantErr { + t.Fatalf("read = %+v, want error %v", r, tt.wantErr) + } + if !tt.wantErr && resultText(t, r) != tt.want { + t.Fatalf("read text = %q, want %q", resultText(t, r), tt.want) + } + }) + } +} + +func TestBuiltins_ReadRejectsOversizedFileWithoutReturningItsContents(t *testing.T) { + sb := newSB(t) + full := strings.Repeat("secret", MaxReadFileBytes/6+1) + if err := sb.WriteFile(context.Background(), "large.txt", []byte(full)); err != nil { + t.Fatal(err) + } + r := Registry()["read"](context.Background(), sb, map[string]any{ + "path": "large.txt", "view_range": []any{float64(1), float64(1)}, + }) + if !r.IsError || !contains(r, "use bash") || contains(r, "secret") { + t.Fatalf("oversized read = %#v", r) + } +} + +func TestBuiltinSchemasOnlyAdvertiseImplementedSemantics(t *testing.T) { + bashProperties := Schema("bash")["properties"].(map[string]any) + if _, advertised := bashProperties["restart"]; advertised { + t.Fatal("bash schema advertises a persistent-shell restart that the executor does not implement") + } + readProperties := Schema("read")["properties"].(map[string]any) + if _, advertised := readProperties["view_range"]; !advertised { + t.Fatal("read schema omitted the implemented view_range") + } +} + func TestBuiltins_EditMissingStringIsError(t *testing.T) { sb := newSB(t) Registry()["write"](context.Background(), sb, map[string]any{"path": "y.txt", "file_text": "abc"}) @@ -152,3 +213,19 @@ func contains(r Result, s string) bool { } return false } + +func resultText(t *testing.T, r Result) string { + t.Helper() + if len(r.Content) != 1 { + t.Fatalf("result content = %#v", r.Content) + } + block, ok := r.Content[0].(map[string]any) + if !ok { + t.Fatalf("result block = %#v", r.Content[0]) + } + text, ok := block["text"].(string) + if !ok { + t.Fatalf("result text = %#v", block["text"]) + } + return text +} diff --git a/internal/agentruntime/tools/registry.go b/internal/agentruntime/tools/registry.go index 0f579ac..136765c 100644 --- a/internal/agentruntime/tools/registry.go +++ b/internal/agentruntime/tools/registry.go @@ -70,7 +70,8 @@ func Names() []string { } // Schema returns the model-facing JSON input_schema for the named tool, or nil -// if the tool is unknown. Shapes follow Anthropic's public tool conventions. +// if the tool is unknown. Shapes adapt useful public tool conventions to the +// semantics Mango actually implements. // // web_fetch/web_search are declared to the model through their native routing // path, but still need legal local schema objects for shared configuration and @@ -88,10 +89,6 @@ func Schema(name string) map[string]any { "type": "string", "description": "The shell command to run.", }, - "restart": map[string]any{ - "type": "boolean", - "description": "Restart the shell session before running.", - }, }, "required": []any{"command"}, } @@ -105,7 +102,7 @@ func Schema(name string) map[string]any { }, "view_range": map[string]any{ "type": "array", - "description": "Optional [start, end] 1-based line range to view.", + "description": "Optional [start, end] 1-based inclusive line range to view; a non-positive end reads through EOF.", "items": map[string]any{"type": "integer"}, "minItems": 2, "maxItems": 2, diff --git a/internal/agentruntime/tools/result_storage.go b/internal/agentruntime/tools/result_storage.go index 82056a7..e391bef 100644 --- a/internal/agentruntime/tools/result_storage.go +++ b/internal/agentruntime/tools/result_storage.go @@ -49,12 +49,16 @@ func MaterializeLargeResult( message := fmt.Sprintf( "\n"+ "Tool output exceeded %d characters. The full output was saved to %s (%d characters).\n"+ - "Use the read tool with view_range to inspect it in chunks.\n\n"+ + "Use bash to inspect it in byte chunks. For example:\n"+ + "dd if=%s bs=%d skip=0 count=1 2>/dev/null\n"+ + "Increase skip by 1 to continue without emitting another oversized result.\n\n"+ "Preview:\n%s\n"+ "", MaxInlineResultChars, resultPath, characters, + resultPath, + MaxReadFileBytes, preview, ) return textResult(message, result.IsError), nil diff --git a/internal/agentruntime/tools/result_storage_test.go b/internal/agentruntime/tools/result_storage_test.go index 53af63e..a8c8890 100644 --- a/internal/agentruntime/tools/result_storage_test.go +++ b/internal/agentruntime/tools/result_storage_test.go @@ -57,3 +57,46 @@ func TestMaterializeLargeResult_LeavesThresholdInline(t *testing.T) { t.Fatal("threshold-sized result should not be written") } } + +func TestMaterializeLargeResult_CanBeInspectedInBoundedBashChunks(t *testing.T) { + sb := newSB(t) + full := strings.Repeat("0123456789", MaxInlineResultChars/10+1) + materialized, err := MaterializeLargeResult( + context.Background(), + sb, + "sevt_chunked", + textResult(full, false), + ) + if err != nil { + t.Fatal(err) + } + message := resultText(t, materialized) + if !strings.Contains(message, "dd if=tool-results/sevt_chunked.txt") || + !strings.Contains(message, "bs=65536 skip=0 count=1") { + t.Fatalf("materialized guidance = %q", message) + } + + readResult := Registry()["read"](context.Background(), sb, map[string]any{ + "path": "tool-results/sevt_chunked.txt", + "view_range": []any{float64(1), float64(1)}, + }) + if !readResult.IsError || !strings.Contains(resultText(t, readResult), "use bash") { + t.Fatalf("oversized line read = %#v", readResult) + } + rematerialized, err := MaterializeLargeResult( + context.Background(), sb, "sevt_read_retry", readResult, + ) + if err != nil { + t.Fatal(err) + } + if resultText(t, rematerialized) != resultText(t, readResult) { + t.Fatalf("read error was materialized again: %#v", rematerialized) + } + + chunk := Registry()["bash"](context.Background(), sb, map[string]any{ + "command": "dd if=tool-results/sevt_chunked.txt bs=65536 skip=1 count=1 2>/dev/null", + }) + if chunk.IsError || resultText(t, chunk) != full[65536:] { + t.Fatalf("bounded bash chunk = %#v", chunk) + } +} diff --git a/internal/sandbox/daytona.go b/internal/sandbox/daytona.go index 936e976..0a0ff77 100644 --- a/internal/sandbox/daytona.go +++ b/internal/sandbox/daytona.go @@ -69,9 +69,6 @@ func NewDaytonaProvider(cfg DaytonaConfig) (Provider, error) { ) } autoPause := cfg.AutoPauseMinutes - if autoPause <= 0 { - autoPause = 15 - } image := strings.TrimSpace(cfg.Image) if image == "" { image = defaultDaytonaImage @@ -288,6 +285,21 @@ func (s *daytonaBox) ReadFile( return s.remote.ReadFile(ctx, full) } +func (s *daytonaBox) ReadFileBounded( + ctx context.Context, + value string, + maxBytes int64, +) ([]byte, bool, error) { + full, err := remoteToolPath( + s.root, value, SessionUploadsRoot, SessionOutputsRoot, SessionSkillsRoot, + SessionRepositoryRoot, + ) + if err != nil { + return nil, false, err + } + return readFileBoundedByCommand(ctx, s, full, maxBytes) +} + func (s *daytonaBox) WriteFile( ctx context.Context, value string, @@ -427,13 +439,12 @@ func (s *daytonaSDKService) Create( sessionKey string, spec Spec, ) (daytonaResource, error) { - autoPause := s.autoPauseMinutes neverDelete := -1 noTTL := 0 base := daytonatypes.SandboxBaseParams{ Name: name, Labels: remoteMetadata(sessionKey), - AutoPauseInterval: &autoPause, + AutoPauseInterval: daytonaAutoPauseInterval(s.autoPauseMinutes), AutoDeleteInterval: &neverDelete, TtlMinutes: &noTTL, NetworkBlockAll: spec.Network == "" || spec.Network == "none", @@ -466,6 +477,13 @@ func (s *daytonaSDKService) Create( }, nil } +func daytonaAutoPauseInterval(minutes int) *int { + if minutes <= 0 { + return nil + } + return &minutes +} + type daytonaSDKRemote struct { sandbox *daytona.Sandbox } diff --git a/internal/sandbox/daytona_test.go b/internal/sandbox/daytona_test.go new file mode 100644 index 0000000..3a4792a --- /dev/null +++ b/internal/sandbox/daytona_test.go @@ -0,0 +1,16 @@ +package sandbox + +import "testing" + +func TestDaytonaAutoPauseInterval(t *testing.T) { + if interval := daytonaAutoPauseInterval(0); interval != nil { + t.Fatalf("zero auto-pause interval = %d, want nil", *interval) + } + if interval := daytonaAutoPauseInterval(-1); interval != nil { + t.Fatalf("negative auto-pause interval = %d, want nil", *interval) + } + interval := daytonaAutoPauseInterval(15) + if interval == nil || *interval != 15 { + t.Fatalf("positive auto-pause interval = %v, want 15", interval) + } +} diff --git a/internal/sandbox/docker.go b/internal/sandbox/docker.go index 931cb91..c384395 100644 --- a/internal/sandbox/docker.go +++ b/internal/sandbox/docker.go @@ -785,6 +785,18 @@ func (s *dockerSandbox) ReadFile(ctx context.Context, path string) ([]byte, erro return content, nil } +func (s *dockerSandbox) ReadFileBounded( + ctx context.Context, + value string, + maxBytes int64, +) ([]byte, bool, error) { + containerPath, err := s.toolFilePath(value, false) + if err != nil { + return nil, false, err + } + return readFileBoundedByCommand(ctx, s, containerPath, maxBytes) +} + // OpenSessionOutputs snapshots the provider-owned writable output mount as a // stream. Docker serializes the directory through its archive API, so worker // memory never scales with deliverable size. The application layer validates diff --git a/internal/sandbox/e2b.go b/internal/sandbox/e2b.go index 49c27e2..86b770e 100644 --- a/internal/sandbox/e2b.go +++ b/internal/sandbox/e2b.go @@ -439,6 +439,21 @@ func (s *e2bLikeSandbox) ReadFile( return s.remote.ReadFile(ctx, full) } +func (s *e2bLikeSandbox) ReadFileBounded( + ctx context.Context, + value string, + maxBytes int64, +) ([]byte, bool, error) { + full, err := remoteToolPath( + s.root, value, SessionUploadsRoot, SessionOutputsRoot, SessionSkillsRoot, + SessionRepositoryRoot, + ) + if err != nil { + return nil, false, err + } + return readFileBoundedByCommand(ctx, s, full, maxBytes) +} + func (s *e2bLikeSandbox) WriteFile( ctx context.Context, value string, diff --git a/internal/sandbox/local.go b/internal/sandbox/local.go index aac1142..19b4b2c 100644 --- a/internal/sandbox/local.go +++ b/internal/sandbox/local.go @@ -191,6 +191,18 @@ func (s *localSandbox) ReadFile(ctx context.Context, path string) ([]byte, error return os.ReadFile(full) } +func (s *localSandbox) ReadFileBounded( + ctx context.Context, + path string, + maxBytes int64, +) ([]byte, bool, error) { + full, err := s.resolve(path) + if err != nil { + return nil, false, err + } + return readFileBoundedByCommand(ctx, s, full, maxBytes) +} + func (s *localSandbox) WriteFile(ctx context.Context, path string, data []byte) error { if err := ctx.Err(); err != nil { return err diff --git a/internal/sandbox/opensandbox.go b/internal/sandbox/opensandbox.go index 07dbcb7..4078a02 100644 --- a/internal/sandbox/opensandbox.go +++ b/internal/sandbox/opensandbox.go @@ -294,6 +294,21 @@ func (s *openSandboxBox) ReadFile( return s.remote.ReadFile(ctx, full) } +func (s *openSandboxBox) ReadFileBounded( + ctx context.Context, + value string, + maxBytes int64, +) ([]byte, bool, error) { + full, err := remoteToolPath( + s.root, value, SessionUploadsRoot, SessionOutputsRoot, SessionSkillsRoot, + SessionRepositoryRoot, + ) + if err != nil { + return nil, false, err + } + return readFileBoundedByCommand(ctx, s, full, maxBytes) +} + func (s *openSandboxBox) WriteFile( ctx context.Context, value string, diff --git a/internal/sandbox/remote_conformance_test.go b/internal/sandbox/remote_conformance_test.go index 4750aa8..faea1ef 100644 --- a/internal/sandbox/remote_conformance_test.go +++ b/internal/sandbox/remote_conformance_test.go @@ -1182,6 +1182,14 @@ func runFakeRemoteContract(t *testing.T, open func() Provider) { if err != nil || !bytes.Equal(got, content) { t.Fatalf("file round trip = %q, %v", got, err) } + bounded, ok := first.(BoundedFileReader) + if !ok { + t.Fatal("remote sandbox does not implement bounded file reads") + } + prefix, truncated, err := bounded.ReadFileBounded(ctx, "nested/state.bin", 4) + if err != nil || !truncated || !bytes.Equal(prefix, content[:4]) { + t.Fatalf("bounded file read = %q, %v, %v", prefix, truncated, err) + } result, err := first.Exec(ctx, Command{ Path: "/bin/sh", Args: []string{"-c", "printf conformance-exec"}, @@ -1435,6 +1443,8 @@ func (h *fakeRemoteHandle) exec( command = strings.ReplaceAll(command, gitRepositoryControlRoot, repositoryControl) workspace, _ := h.fullPath(SessionRepositoryRoot) command = strings.ReplaceAll(command, SessionRepositoryRoot, workspace) + daytonaRoot, _ := h.fullPath(defaultDaytonaRoot) + command = strings.ReplaceAll(command, defaultDaytonaRoot, daytonaRoot) process := exec.CommandContext(ctx, "/bin/sh", "-c", command) process.Dir = h.resource.root process.Env = []string{"PATH=/usr/bin:/bin", "COPYFILE_DISABLE=1"} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 7965e0a..0a5c5a7 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "io" + "strings" "time" "github.com/yanpgwang/mango/internal/domain" @@ -141,6 +142,55 @@ type Sandbox interface { Destroy(ctx context.Context) error } +// BoundedFileReader is the file-tool data plane for reads that must not scale +// worker memory with an untrusted sandbox file. All Mango providers implement +// it by validating the same path authority as ReadFile, then returning at most +// maxBytes and reporting whether more bytes exist. +type BoundedFileReader interface { + ReadFileBounded( + ctx context.Context, + path string, + maxBytes int64, + ) (data []byte, truncated bool, err error) +} + +func readFileBoundedByCommand( + ctx context.Context, + executor interface { + Exec(context.Context, Command) (*Result, error) + }, + resolvedPath string, + maxBytes int64, +) ([]byte, bool, error) { + if maxBytes <= 0 || maxBytes >= maxOutput { + return nil, false, fmt.Errorf( + "sandbox: bounded read limit must be between 1 and %d bytes", + maxOutput-1, + ) + } + result, err := executor.Exec(ctx, Command{ + Path: "head", + Args: []string{"-c", fmt.Sprintf("%d", maxBytes+1), "--", resolvedPath}, + }) + if err != nil { + return nil, false, err + } + if result.TimedOut { + return nil, false, fmt.Errorf("sandbox: bounded read timed out") + } + if result.ExitCode != 0 { + message := strings.TrimSpace(string(result.Stderr)) + if message == "" { + message = fmt.Sprintf("head exited with code %d", result.ExitCode) + } + return nil, false, fmt.Errorf("sandbox: bounded read: %s", message) + } + if int64(len(result.Stdout)) > maxBytes { + return append([]byte(nil), result.Stdout[:maxBytes]...), true, nil + } + return append([]byte(nil), result.Stdout...), false, nil +} + // Provider owns sandbox resources outside the agent loop. Create must expose a // stable provider-side lookup key so a retry after a lost response resolves the // same logical resource. When a provider cannot atomically create-if-absent, diff --git a/internal/sandbox/sandboxtest/conformance.go b/internal/sandbox/sandboxtest/conformance.go index 5085b42..d9c74aa 100644 --- a/internal/sandbox/sandboxtest/conformance.go +++ b/internal/sandbox/sandboxtest/conformance.go @@ -473,6 +473,29 @@ func Run(t *testing.T, cfg Config) { if !bytes.Equal(got, content) { t.Fatalf("file round trip = %q, want %q", got, content) } + bounded, ok := box.(sandbox.BoundedFileReader) + if !ok { + t.Fatal("sandbox does not implement bounded file reads") + } + prefix, truncated, err := bounded.ReadFileBounded(ctx, "nested/state.bin", 4) + if err != nil { + t.Fatalf("ReadFileBounded: %v", err) + } + if !truncated || !bytes.Equal(prefix, content[:4]) { + t.Fatalf("bounded file read = %q, %v; want %q, true", prefix, truncated, content[:4]) + } + exact, truncated, err := bounded.ReadFileBounded( + ctx, "nested/state.bin", int64(len(content)), + ) + if err != nil { + t.Fatalf("exact ReadFileBounded: %v", err) + } + if truncated || !bytes.Equal(exact, content) { + t.Fatalf("exact bounded file read = %q, %v; want %q, false", exact, truncated, content) + } + if _, _, err := bounded.ReadFileBounded(ctx, "../escape", 4); err == nil { + t.Fatal("ReadFileBounded accepted a path outside the workspace") + } result, err := box.Exec(ctx, sandbox.Command{ Path: cfg.ShellPath,