diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2d347e74..1daad049e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1088,7 +1088,11 @@ qualify this layout. The read-only deployment needs neither writer setting. Transfer a complete bounded body in acknowledged 64 KiB frames before invoking the existing installer, verify the declared digest, and run no model for upload. -Keep the existing private 50 MiB bound distinct from upstream protocol limits. +Keep the private 50 MiB transfer bound distinct from the official 5 MiB decoded +inline bound, which the API checks before any Runtime work. Files.create uses the +installer's explicit create mode: parents are created without following links and +an existing path is never replaced. Initial Session files and Skills keep the +installer's replace mode; do not change one caller's mode for another. The dedicated Runtime excludes execution while receiving or applying a write; malformed, incomplete or expired transfers cannot reach the installer. Exact commit/rejection receipts release the mutation owner. Missing or ambiguous diff --git a/apps/parsar-daemon/internal/agent/workspace_write.go b/apps/parsar-daemon/internal/agent/workspace_write.go index 77d69c939..237b73997 100644 --- a/apps/parsar-daemon/internal/agent/workspace_write.go +++ b/apps/parsar-daemon/internal/agent/workspace_write.go @@ -3,6 +3,7 @@ package agent import ( "context" "errors" + "fmt" ) type WorkspaceWriteResult struct { @@ -22,3 +23,10 @@ var ( ErrWorkspaceWriteRejected = errors.New("workspace write rejected") ErrWorkspaceWriteUncertain = errors.New("workspace write outcome uncertain") ) + +// Known Files.create destination refusals. Both wrap ErrWorkspaceWriteRejected: +// nothing was installed. +var ( + ErrWorkspaceWriteDirectory = fmt.Errorf("%w: destination is a directory", ErrWorkspaceWriteRejected) + ErrWorkspaceWriteUnsafe = fmt.Errorf("%w: destination exists or its path is not a plain directory chain", ErrWorkspaceWriteRejected) +) diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write.go b/apps/parsar-daemon/internal/dispatch/workspace_write.go index 338e3bdd8..751cdb807 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_write.go @@ -137,6 +137,17 @@ func workspaceWriteResult(write agent.WorkspaceWriteResult, err error, size int) if err == nil && write.SizeBytes == int64(size) { return proto.WorkspaceWriteResultPayload{Outcome: "completed", SizeBytes: size} } + for _, conflict := range []struct { + err error + reason string + }{ + {agent.ErrWorkspaceWriteDirectory, proto.WorkspaceWriteReasonDirectory}, + {agent.ErrWorkspaceWriteUnsafe, proto.WorkspaceWriteReasonUnsafe}, + } { + if errors.Is(err, conflict.err) { + return proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: "write_rejected", Reason: conflict.reason} + } + } for _, failure := range []struct { err error code string diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go index d33f46688..d3d71f805 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go @@ -165,3 +165,29 @@ func TestLocalUploadUnknownRetainsOwner(t *testing.T) { t.Fatal("shutdown declared uncertain mutation settled") } } + +func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) { + for helperError, reason := range map[string]string{ + "destination_directory": proto.WorkspaceWriteReasonDirectory, + "unsafe_destination": proto.WorkspaceWriteReasonUnsafe, + "write_failed": "", + } { + t.Run(helperError, func(t *testing.T) { + r, sender, request, _ := localWriterRouter(t, `{"version":1,"outcome":"failed","error":"`+helperError+`"}`) + id := uuid.NewString() + for _, p := range []proto.WorkspaceWritePayload{request, {Step: "chunk", Data: []byte("abc")}, {Step: "commit"}} { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, p)); err != nil { + t.Fatal(err) + } + } + if got := waitWorkspaceWrite(t, sender, id, "rejected"); got.ErrorCode != "write_rejected" || got.Reason != reason { + t.Fatal(got) + } + next := uuid.NewString() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, next, request)); err != nil { + t.Fatal(err) + } + waitWorkspaceWrite(t, sender, next, "ready") + }) + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/write.go b/apps/parsar-daemon/internal/localworkspace/write.go index fc391c51a..15aaf726d 100644 --- a/apps/parsar-daemon/internal/localworkspace/write.go +++ b/apps/parsar-daemon/internal/localworkspace/write.go @@ -52,7 +52,9 @@ func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []by operation, cancel := context.WithTimeout(context.WithoutCancel(ctx), 65*time.Second) defer cancel() digest := sha256.Sum256(data) - cmd := exec.CommandContext(operation, w.helper, b.workspace, path, strconv.Itoa(len(data)), w.staging) + // This writer serves only public Files.create, so it always selects the + // helper's create mode: create missing parents and never replace. + cmd := exec.CommandContext(operation, w.helper, b.workspace, path, strconv.Itoa(len(data)), w.staging, "create") cmd.Dir = "/" cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} cmd.Stdin = io.MultiReader(bytes.NewReader(data), bytes.NewReader(digest[:])) @@ -96,8 +98,15 @@ func decodeWrite(data []byte, expected int) (agent.WorkspaceWriteResult, error) if wire.Outcome == "completed" && wire.Error == nil && wire.SizeBytes != nil && *wire.SizeBytes == int64(expected) { return agent.WorkspaceWriteResult{SizeBytes: *wire.SizeBytes}, nil } - if wire.Outcome == "failed" && wire.SizeBytes == nil && wire.Error != nil && (*wire.Error == "invalid_input" || *wire.Error == "write_failed") { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteRejected + if wire.Outcome == "failed" && wire.SizeBytes == nil && wire.Error != nil { + switch *wire.Error { + case "invalid_input", "write_failed": + return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteRejected + case "destination_directory": + return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteDirectory + case "unsafe_destination": + return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsafe + } } return agent.WorkspaceWriteResult{}, invalid } diff --git a/apps/parsar-daemon/internal/localworkspace/write_test.go b/apps/parsar-daemon/internal/localworkspace/write_test.go index 70f83121b..774208332 100644 --- a/apps/parsar-daemon/internal/localworkspace/write_test.go +++ b/apps/parsar-daemon/internal/localworkspace/write_test.go @@ -60,7 +60,8 @@ func TestWriteRejectsUnsafeBindings(t *testing.T) { func TestWriteReceiptAndCredentialBoundary(t *testing.T) { t.Setenv("PARSAR_PRIVATE_CREDENTIAL", "synthetic-secret") - b := writableBinding(t, "[ -z \"$PARSAR_PRIVATE_CREDENTIAL\" ] || exit 13\ncat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":3}'\n") + // Files.create always selects the helper's explicit create mode. + b := writableBinding(t, "[ -z \"$PARSAR_PRIVATE_CREDENTIAL\" ] || exit 13\n[ \"$#\" = 5 ] && [ \"$5\" = create ] || exit 14\ncat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":3}'\n") result, err := b.WriteWorkspaceFile(t.Context(), "file", []byte{0, 1, 2}) if err != nil || result.SizeBytes != 3 { t.Fatalf("write: %+v %v", result, err) @@ -144,14 +145,22 @@ func TestWriteReceiptValidation(t *testing.T) { `{"version":1,"outcome":"failed","error":"other"}`, `{"version":1,"outcome":"completed","size_bytes":0} {}`, `{"version":1,"outcome":"completed","size_bytes":0,"extra":true}`, + `{"version":1,"outcome":"unknown","error":"unsafe_destination"}`, + `{"version":1,"outcome":"failed","size_bytes":0,"error":"destination_directory"}`, } { if _, err := decodeWrite([]byte(response), 0); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { t.Fatalf("unsafe receipt %s: %v", response, err) } } - for _, code := range []string{"invalid_input", "write_failed"} { - if _, err := decodeWrite([]byte(fmt.Sprintf(`{"version":1,"outcome":"failed","error":%q}`, code)), 0); !errors.Is(err, agent.ErrWorkspaceWriteRejected) { - t.Fatal(err) + for code, want := range map[string]error{ + "invalid_input": agent.ErrWorkspaceWriteRejected, + "write_failed": agent.ErrWorkspaceWriteRejected, + "destination_directory": agent.ErrWorkspaceWriteDirectory, + "unsafe_destination": agent.ErrWorkspaceWriteUnsafe, + } { + _, err := decodeWrite([]byte(fmt.Sprintf(`{"version":1,"outcome":"failed","error":%q}`, code)), 0) + if err != want || !errors.Is(err, agent.ErrWorkspaceWriteRejected) { + t.Fatal(code, err) } } } @@ -165,40 +174,47 @@ func TestLocalWriteNativeInstaller(t *testing.T) { if err := b.bindWriter(helper, b.writer.staging); err != nil { t.Fatal(err) } + escape := filepath.Join(b.workspace, "escape") + if err := os.Symlink(b.writer.staging, escape); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(b.workspace, "setup.txt"), []byte("setup"), 0600); err != nil { + t.Fatal(err) + } + // Missing parents are created; an existing destination is never replaced. for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} { + path := fmt.Sprintf("n1/n2/file-%d", size) data := bytes.Repeat([]byte{0, 255, 17}, (size+2)/3)[:size] - got, err := b.WriteWorkspaceFile(t.Context(), "file", data) + got, err := b.WriteWorkspaceFile(t.Context(), path, data) if err != nil || got.SizeBytes != int64(size) { t.Fatalf("size %d: %+v %v", size, got, err) } - actual, err := os.ReadFile(filepath.Join(b.workspace, "file")) + actual, err := os.ReadFile(filepath.Join(b.workspace, path)) if err != nil || !bytes.Equal(actual, data) { t.Fatalf("size %d bytes differ: %v", size, err) } } - old := filepath.Join(b.workspace, "file") - if err := os.WriteFile(old, []byte("original"), 0600); err != nil { - t.Fatal(err) - } - alias := filepath.Join(b.workspace, "alias") - if err := os.Link(old, alias); err != nil { - t.Fatal(err) + if info, err := os.Lstat(filepath.Join(b.workspace, "n1")); err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { + t.Fatal("parent directory", info, err) } - if _, err := b.WriteWorkspaceFile(t.Context(), "file", []byte("replacement")); err != nil { - t.Fatal(err) - } - if data, err := os.ReadFile(alias); err != nil || string(data) != "original" { - t.Fatal("hard-link contents changed", err) - } - escape := filepath.Join(b.workspace, "escape") - if err := os.Symlink(b.writer.staging, escape); err != nil { - t.Fatal(err) - } - for _, path := range []string{"escape/new", "escape", "missing/file"} { - if _, err := b.WriteWorkspaceFile(t.Context(), path, []byte("denied")); !errors.Is(err, agent.ErrWorkspaceWriteRejected) { + for path, want := range map[string]error{ + "n1/n2/file-3": agent.ErrWorkspaceWriteUnsafe, + "setup.txt": agent.ErrWorkspaceWriteUnsafe, + "escape": agent.ErrWorkspaceWriteUnsafe, + "escape/new": agent.ErrWorkspaceWriteUnsafe, + "n1/n2": agent.ErrWorkspaceWriteDirectory, + "n1": agent.ErrWorkspaceWriteDirectory, + } { + if _, err := b.WriteWorkspaceFile(t.Context(), path, []byte("denied")); err != want { t.Fatalf("%s: %v", path, err) } } + if data, err := os.ReadFile(filepath.Join(b.workspace, "n1/n2/file-3")); err != nil || !bytes.Equal(data, []byte{0, 255, 17}) { + t.Fatal("existing file changed", err) + } + if data, err := os.ReadFile(filepath.Join(b.workspace, "setup.txt")); err != nil || string(data) != "setup" { + t.Fatal("untracked file changed", err) + } if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil { t.Fatal("known rejection blocked next write", err) } diff --git a/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.test.tsx b/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.test.tsx index 52ffafded..834abdc4c 100644 --- a/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.test.tsx +++ b/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.test.tsx @@ -44,7 +44,8 @@ describe("EnvironmentFileCreatePanel", () => { expect(validInlineEnvironmentFilePath(`/workspace/${"界".repeat(1_363)}`)).toBe(false); }); - it("enforces the decoded 50 MiB boundary and encodes multibyte bytes as standard Base64", () => { + it("enforces the decoded 5 MiB boundary and encodes multibyte bytes as standard Base64", () => { + expect(maxInlineEnvironmentFileBytes).toBe(5 * 1024 * 1024); expect(validInlineEnvironmentFileByteLength(maxInlineEnvironmentFileBytes)).toBe(true); expect(validInlineEnvironmentFileByteLength(maxInlineEnvironmentFileBytes + 1)).toBe(false); expect(validInlineEnvironmentFileByteLength(-1)).toBe(false); diff --git a/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.tsx b/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.tsx index cd3d7feb1..035c1096d 100644 --- a/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.tsx +++ b/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.tsx @@ -101,7 +101,7 @@ export function EnvironmentFileCreatePanel({