Repository: oras-project/oras

Issue:
improve error message of `oras cp`
### What happened in your environment?

Currently the error output of `oras cp` is confusing
```console
oras cp --to-distribution-spec v1.1-referrers-tag $source_repo $target_repo
...
Error response from registry: sha256:0f58dd64e1e2b1f2c96e2b2cab30402540724a399061b035051534808114144a: not found	
```

### What did you expect to happen?

The returned error should clarify
1) Where the error occurs in (which target, source or destination) 
2) What operation causes the error.

Depends on oras-project/oras-go#677

### How can we reproduce it?

Run `oras cp` with error returned during copying.

### What is the version of your ORAS CLI?

v1.2.0

### What is your OS environment?

Ubuntu 20.04

### Are you willing to submit PRs to fix it?

- [ ] Yes, I am willing to fix it.

Discussion:
@Wwwsylvia: Starting from [`v2.6.0`](https://github.com/oras-project/oras-go/releases/tag/v2.6.0), the underlying `oras-go` library returns structured errors for copy operations.

For example, a "not found" error during the "Resolve" operation on the source target is represented as:

```json
{
    "Op": "Resolve",
    "Origin": "source",
    "Err": "not found"
}
```

Similarly, an "unauthorized" error during the "Push" operation on the destination target is represented as:

```json
{
    "Op": "Push",
    "Origin": "destination",
    "Err": "unauthorized"
}
```

Based on these structured errors, we can build a more user-friendly error message. 

Previously, the CLI prepended errors with the text "Error response from registry" to indicate that the error came from the registry rather than the ORAS client.

A new error message format can be considered as follows:


> Error: operation "`<Op>`" failed on `<Origin>` `<target_type>` "`<target_path>`" (reference: "`<target_reference>`"): `<Err>`


Where `<target_type>` can be OCI layout, remote repository, or directory. `<target_path>` can be a path to a repository or a directory. `<target_reference>` can be either a tag or a digest.

This template clearly indicates which operation failed, where it occurred, and the reason for the failure.

Here are some examples:

## Examples

### Copy error

#### Before

```console
$ oras cp docker.io/library/hello-world:v1 localhost:5000/test:v1
Error response from registry: failed to perform "FetchReference" on source: docker.io/library/hello-world:v1: not found
```

#### After

```console
$ oras cp docker.io/library/hello-world:v1 localhost:5000/test:v1
Error: operation "FetchReference" failed on source remote repository "docker.io/library/hello-world" (reference: "v1"): docker.io/library/hello-world:v1: not found
```

### Pull error

#### Before
```console
$ oras pull --oci-layout test:v1
Error: failed to perform "Fetch" on source: sha256:f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2: application/vnd.oci.image.layer.v1.tar: not found
```

#### After
```console
$ oras pull --oci-layout test_broken:v1
Error: operation "Fetch" failed on source OCI layout "test_broken" (reference: "v1"): sha256:f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2: application/vnd.oci.image.layer.v1.tar: not found
```

### Push error

#### Before
```console
$ oras push localhost:9999/test:v1
Error: failed to perform "Exists" on destination: Head "http://localhost:9999/v2/test/manifests/sha256:8d0f5635872556d472bbc0353a77f407d1f7a5ace6aab91b238da0f13d4826fb": dial tcp 127.0.0.1:9999: connect: connection refused
```

#### After

```console
$ oras push localhost:9999/test:v1
Error: operation "Exists" failed on destination remote repository "localhost:9999/test" (reference: "v1"): Head "http://localhost:9999/v2/test/manifests/sha256:ccb984cadd844dbe41f9f0d2a5a7f935c451053386d076e3cdd07faba7cba084": dial tcp 127.0.0.1:9999: connect: connection refused
```

### Attach error

#### Before

```console
$ oras attach docker.io/library/hello-world:latest test --artifact-type "test/type"
Error response from registry: failed to perform "Push" on destination: unauthorized: authentication required: [map[Action:pull Class: Name:library/hello-world Type:repository] map[Action:push Class: Name:library/hello-world Type:repository]]
```

#### After

```console
$ oras attach docker.io/library/hello-world:latest test --artifact-type "test/type"
Error: operation "Push" failed on destination remote repository "docker.io/library/hello-world" (reference: "latest"): unauthorized: authentication required: [map[Action:pull Class: Name:library/hello-world Type:repository] map[Action:push Class: Name:library/hello-world Type:repository]]
```




@FeynmanZhou: Thanks @Wwwsylvia  for proposing the error message improvement. 

From an user Pov, I think it would be good to make the error message more friendly and actionable by following this guidance https://github.com/oras-project/oras/blob/main/docs/proposals/error-handling-guideline.md.

For the first example, the error message is too verbose and technical: "operation 'FetchReference'" and "source remote repository" are not terms most users will immediately understand. The image name and tag are repeated. In additonal, it doesn't help the user figure out why it's not found or what to do next.

How about:

```
$ oras cp docker.io/library/hello-world:v1 localhost:5000/test:v1
Error: Could not find image "hello-world:v1" on the source registry.
Please check if the tag "v1" exists in the repository "library/hello-world" and you have access to the repository.
```

For the second example, "operation 'Exists'" and full HEAD URL are internal implementation details. Long nested error without clear suggestion seems not helpful to users. 

How about:

```
$ oras push localhost:9999/test:v1
Error: Could not connect to target registry at localhost:9999.
Connection was refused. Is the registry available?
```

For the third example, `map[Action:pull ...]` are internal Go struct dumps and somehow confusing to end users. It doesn’t tell users how to mitigate the error.

How about:

```
$ oras attach docker.io/library/hello-world:latest test --artifact-type "test/type"
Error: Unauthorized to atach artifacts to an image in the registry "docker.io/library/hello-world:latest".
You need right access permissions of the registry to attact to an image.
Please log in using `oras login` and make sure your account has write access to the repository.
```@Wwwsylvia: @FeynmanZhou Thanks for the suggestions. I understand that actionable error messages would be much more user-friendly.

However, there is a challenge: the copy function operates like a black box to the CLI, and the error types it returns are varied and dynamic. From the CLI's perspective, it is difficult to determine the exact error type and provide a specific actionable message.

For example, for the command `oras cp src.registry.com/hello:latest dst.registry.com/hello:latest`, the copy function can return a "not found" error in the `Err` field in the following scenarios:

- The source tag `latest` is not found (`Op` = "Resolve", `Err` = "latest not found").
- The source tag is resolved to a digest, but the digest is not found (`Op` = "Fetch", `Err` = "sha256:abc123 not found").
- One of the layers in the source artifact is not found (`Op` = "Fetch", `Err` = "sha256:def456 not found").

Handling all possible error types and providing specific actionable messages for each case requires significant effort.

I think we can start with a generic error message and improve it with more friendly customization and action recommendations for the most common cases.


@FeynmanZhou: > I think we can start with a generic error message and improve it with more friendly customization and action recommendations for the most common cases.

@Wwwsylvia Sounds good to me. I also agree with "the template should clearly indicates which operation failed, where it occurred, and the reason for the failure". Can we agree on the following guiding principles? Are these feasible?

- Avoid too verbose and technical terms in the error message, such as "operation 'FetchReference'". Operation-level information can be a part of the debug logs for developers. If necessary, we could prompt users in the error message to use `-d` to get debug logs
- Do not output the internal Go struct dumps `map[Action:pull ...]`. Similar as above
- Keep the error message short and neat. Avoid repeated information
- If the error type can be identified in a few common cases, provide actionable suggestions as much as we can
@Wwwsylvia: > Operation-level information can be a part of the debug logs for developers. If necessary, we could prompt users in the error message to use -d to get debug logs

IMO Including operation information in the error message is not harmful. If we only include it in the debug logs, users would need to rerun the failed command with `--debug` to get the information.

> Do not output the internal Go struct dumps map[Action:pull ...].

The detailed error is part of the underlying `Err` field returned by `oras-go`. In this case, the error `unauthorized: authentication required: [map[Action:pull Class: Name:library/hello-world Type:repository] map[Action:push Class: Name:library/hello-world Type:repository]]` originates from the registry response.

> Keep the error message short and neat. Avoid repeated information

Do you have any suggestions for improving the error message template?
`Error: operation "<Op>" failed on <Origin> <target_type> "<target_path>" (reference: "<target_reference>"): <Err>`@FeynmanZhou: Per our discussion, here is what we aligned for the error message structure

```
{Error}: {Describe what happended} 
{Error response from registry}: {Error description (HTTP status code can be printed out if any)}
{{Inner Error}: {Describe what happended behand the scenes to help troubleshoot the root cause. This error message generated by the underlying library such as oras-go.}
[Usage: {Command usage}]: {If wrong command or flag used}
[{Recommended solution}]: {Optional. If the error type can be identified in a few common cases, provide actionable suggestions as much as we can.}
```

```
$ oras cp docker.io/library/hello-world:v1 localhost:5000/test:v1
 
Error: failed to copy from source remote repository "docker.io/library/hello-world" (reference: "v1")
Inner error: operation "FetchReference" failed: docker.io/library/hello-world:v1: not found
Error from registry: unauthorized: authentication required: [map[Action:pull Class: Name:library/hello-world Type:repository] map[Action:push Class: Name:library/hello-world Type:repository]]
```@TerryHowe: I think printing stuff like `[map[Action:pull Class: Name:library/hello-world Type:repository] map[Action:push Class: Name:library/hello-world Type:repository]` is a pretty terrible user experience. Most of our users are not programmers and that would be confusing.@TerryHowe: I also think that since the user specified `"docker.io/library/hello-world:v1"` it is clearer than `"docker.io/library/hello-world" (reference: "v1")`

We wouldn't really know if the reference or the repository were missing.@Wwwsylvia: > I think printing stuff like `[map[Action:pull Class: Name:library/hello-world Type:repository] map[Action:push Class: Name:library/hello-world Type:repository]` is a pretty terrible user experience. Most of our users are not programmers and that would be confusing.

The error response `[map[Action:pull Class: Name:library/hello-world Type:repository] map[Action:push Class: Name:library/hello-world Type:repository]]` is directly returned from the registry (in this case, docker.io). We just print it out as it is. Different registries may return different messages. 

I might have picked a bad instance, but I don't think we should hide the error messages from the registry.@Wwwsylvia: OK. Let's keep it simple.

How about this error message format:

> Error from `{source|destination}` `{registry|OCI layout}` for "`{reference}`": `{error}`

## Examples

### Copy Error

```console
$ oras cp docker.io/library/hello-world:v1 localhost:5000/test:v1
Error from source registry for "docker.io/library/hello-world:v1": docker.io/library/hello-world:v1: not found
```
### Pull Error

```console
$ oras pull --oci-layout test_broken:v1
Error from source OCI layout for "test_broken:v1": sha256:f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2: application/vnd.oci.image.layer.v1.tar: not found
```

### Push Error

```console
$ oras push localhost:9999/test:v1
Error from destination registry for "localhost:9999/test:v1": Head "http://localhost:9999/v2/test/manifests/sha256:b4c25e2c630258c6bbddafa1dee0808279d74b28d9cf2bf32898fa762a6b176b": dial tcp 127.0.0.1:9999: connect: connection refused
```

### Attach Error

```console
$ oras attach ghcr.io/oras-project/oras:main --artifact-type "test/type" test
Error from destination registry for "docker.io/library/hello-world:latest": unauthorized: authentication required: [map[Action:pull Class: Name:library/hello-world Type:repository] map[Action:push Class: Name:library/hello-world Type:repository]]
```

@TerryHowe @FeynmanZhou @shizhMSFT @qweeah  What do you think?

---
Update: For OCI layout, maybe using the word `oci-layout` instead of `OCI layout` to be consistent with other output
> Error from source oci-layout for "test_broken:v1": sha256:f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2: application/vnd.oci.image.layer.v1.tar: not found
@FeynmanZhou: @Wwwsylvia  The error message format looks good to me. Is it necessary to apply `{source|destination}` to `oras pull/push/attach`? 
I think this format only works for `oras copy` since it has two references. 

Solution patch:
diff --git a/cmd/oras/internal/errors/errors.go b/cmd/oras/internal/errors/errors.go
index a19d245..1eba032 100644
--- a/cmd/oras/internal/errors/errors.go
+++ b/cmd/oras/internal/errors/errors.go
@@ -22,6 +22,7 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
+	"oras.land/oras-go/v2"
 	"oras.land/oras-go/v2/registry/remote/auth"
 	"oras.land/oras-go/v2/registry/remote/errcode"
 )
@@ -87,7 +88,7 @@ func CheckArgs(checker func(args []string) (bool, string), Usage string) cobra.P
 
 // Modifier modifies the error during cmd execution.
 type Modifier interface {
-	Modify(cmd *cobra.Command, err error) (modifiedErr error, modified bool)
+	ModifyError(cmd *cobra.Command, err error) (modifiedErr error, modified bool)
 }
 
 // Command returns an error-handled cobra command.
@@ -96,7 +97,7 @@ func Command(cmd *cobra.Command, handler Modifier) *cobra.Command {
 	cmd.RunE = func(cmd *cobra.Command, args []string) error {
 		err := runE(cmd, args)
 		if err != nil {
-			err, _ = handler.Modify(cmd, err)
+			err, _ = handler.ModifyError(cmd, err)
 			return err
 		}
 		return nil
@@ -104,18 +105,28 @@ func Command(cmd *cobra.Command, handler Modifier) *cobra.Command {
 	return cmd
 }
 
-// TrimErrResp tries to trim toTrim from err.
-func TrimErrResp(err error, toTrim error) error {
-	var inner error
-	if errResp, ok := toTrim.(*errcode.ErrorResponse); ok {
-		if len(errResp.Errors) == 0 {
-			return fmt.Errorf("recognizable error message not found: %w", toTrim)
-		}
-		inner = errResp.Errors
-	} else {
-		return err
+// ReportErrResp returns the inner error message from errResp.Errors.
+// If errResp.Errors is empty, it returns the original errResp.
+func ReportErrResp(errResp *errcode.ErrorResponse) error {
+	if len(errResp.Errors) == 0 {
+		// Example error string:
+		// GET "registry.example.com/v2/_catalog": response status code 401: 401
+		return errResp
+	}
+	// Example error string:
+	// unauthorized: authentication required
+	return errResp.Errors
+}
+
+// UnwrapCopyError extracts the underlying error from an oras.CopyError.
+// If err is of type *oras.CopyError, it returns the inner error (copyErr.Err).
+// Otherwise, it returns the original error unchanged.
+func UnwrapCopyError(err error) error {
+	var copyErr *oras.CopyError
+	if errors.As(err, &copyErr) {
+		return copyErr.Err
 	}
-	return reWrap(err, toTrim, inner)
+	return err
 }
 
 // TrimErrBasicCredentialNotFound trims the credentials from err.
@@ -144,21 +155,19 @@ func TrimErrBasicCredentialNotFound(err error) error {
 	return reWrap(err, toTrim, auth.ErrBasicCredentialNotFound)
 }
 
-// reWrap re-wraps errA to errC and trims out errB, returns errC if scrub fails.
-// +---------- errA ----------+
-// |         +---- errB ----+ |      +---- errA ----+
-// |         |    errC      | |  =>  |     errC     |
-// |         +--------------+ |      +--------------+
-// +--------------------------+
-func reWrap(errA, errB, errC error) error {
-	// TODO: trim dedicated error type when
-	// https://github.com/oras-project/oras-go/issues/677 is done
-	contentA := errA.Error()
-	contentB := errB.Error()
-	if idx := strings.Index(contentA, contentB); idx > 0 {
-		return fmt.Errorf("%s%w", contentA[:idx], errC)
+// reWrap re-wraps outer to inner by trimming out mid, returns inner if extraction fails.
+// +---------- outer ----------+      +------ outer ------+
+// |         +---- mid ----+   |      |                   |
+// |         |    inner    |   |  =>  |       inner       |
+// |         +-------------+   |      |                   |
+// +---------------------------+      +-------------------+
+func reWrap(outer, mid, inner error) error {
+	msgOuter := outer.Error()
+	msgMid := mid.Error()
+	if idx := strings.Index(msgOuter, msgMid); idx > 0 {
+		return fmt.Errorf("%s%w", msgOuter[:idx], inner)
 	}
-	return errC
+	return inner
 }
 
 // NewErrEmptyTagOrDigest creates a new error based on the reference string.
diff --git a/cmd/oras/internal/option/binary_target.go b/cmd/oras/internal/option/binary_target.go
index 2d2e466..7939876 100644
--- a/cmd/oras/internal/option/binary_target.go
+++ b/cmd/oras/internal/option/binary_target.go
@@ -16,10 +16,13 @@ limitations under the License.
 package option
 
 import (
+	"errors"
+	"fmt"
 	"sync"
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
+	"oras.land/oras-go/v2"
 	oerrors "oras.land/oras/cmd/oras/internal/errors"
 )
 
@@ -65,10 +68,35 @@ func (target *BinaryTarget) Parse(cmd *cobra.Command) error {
 	return Parse(cmd, target)
 }
 
-// Modify handles error during cmd execution.
-func (target *BinaryTarget) Modify(cmd *cobra.Command, err error) (error, bool) {
-	if modifiedErr, modified := target.From.Modify(cmd, err); modified {
+// ModifyError handles error during cmd execution.
+func (target *BinaryTarget) ModifyError(cmd *cobra.Command, err error) (error, bool) {
+	var copyErr *oras.CopyError
+	if !errors.As(err, &copyErr) {
+		return target.modifyError(cmd, err)
+	}
+
+	err = copyErr.Err // extract the inner error
+	var errTarget Target
+	switch copyErr.Origin {
+	case oras.CopyErrorOriginSource:
+		errTarget = target.From
+	case oras.CopyErrorOriginDestination:
+		errTarget = target.To
+	default:
+		err, _ := target.modifyError(cmd, err)
+		return err, true
+	}
+
+	err, _ = target.modifyError(cmd, err)
+	// Example: Error from source registry for "localhost:5000/test:v1":
+	// Example: Error from destination oci-layout for "oci-dir:v1":
+	cmd.SetErrPrefix(fmt.Sprintf("Error from %s %s for %q:", copyErr.Origin, errTarget.Type, errTarget.RawReference))
+	return err, true
+}
+
+func (target *BinaryTarget) modifyError(cmd *cobra.Command, err error) (error, bool) {
+	if modifiedErr, modified := target.From.ModifyError(cmd, err); modified {
 		return modifiedErr, modified
 	}
-	return target.To.Modify(cmd, err)
+	return target.To.ModifyError(cmd, err)
 }
diff --git a/cmd/oras/internal/option/remote.go b/cmd/oras/internal/option/remote.go
index 5794f07..53da29f 100644
--- a/cmd/oras/internal/option/remote.go
+++ b/cmd/oras/internal/option/remote.go
@@ -394,18 +394,17 @@ func (remo *Remote) isPlainHttp(registry string) bool {
 	return plainHTTP
 }
 
-// Modify modifies error during cmd execution.
-func (remo *Remote) Modify(cmd *cobra.Command, err error) (error, bool) {
-	var errResp *errcode.ErrorResponse
-
+// ModifyError modifies error during cmd execution.
+func (remo *Remote) ModifyError(cmd *cobra.Command, err error) (error, bool) {
 	if errors.Is(err, auth.ErrBasicCredentialNotFound) {
 		return remo.DecorateCredentialError(err), true
 	}
 
+	var errResp *errcode.ErrorResponse
 	if errors.As(err, &errResp) {
 		cmd.SetErrPrefix(oerrors.RegistryErrorPrefix)
 		return &oerrors.Error{
-			Err: oerrors.TrimErrResp(err, errResp),
+			Err: oerrors.ReportErrResp(errResp),
 		}, true
 	}
 	return err, false
diff --git a/cmd/oras/internal/option/target.go b/cmd/oras/internal/option/target.go
index 84cbea1..333ea26 100644
--- a/cmd/oras/internal/option/target.go
+++ b/cmd/oras/internal/option/target.go
@@ -236,51 +236,56 @@ func (target *Target) EnsureReferenceNotEmpty(cmd *cobra.Command, allowTag bool)
 	return nil
 }
 
-// Modify handles error during cmd execution.
-func (target *Target) Modify(cmd *cobra.Command, err error) (error, bool) {
+// ModifyError handles error during cmd execution.
+func (target *Target) ModifyError(cmd *cobra.Command, err error) (error, bool) {
 	if target.IsOCILayout {
+		// short circuit for non-remote targets
 		return err, false
 	}
 
+	// handle errors for remote targets
 	if errors.Is(err, auth.ErrBasicCredentialNotFound) {
 		return target.DecorateCredentialError(err), true
 	}
 
 	if errors.Is(err, errdef.ErrNotFound) {
+		// special handling for not found error returned by registry target
 		cmd.SetErrPrefix(oerrors.RegistryErrorPrefix)
 		return err, true
 	}
 
 	var errResp *errcode.ErrorResponse
-	if errors.As(err, &errResp) {
-		ref := registry.Reference{Registry: target.RawReference}
+	if !errors.As(err, &errResp) {
+		// short circuit if the error is not an ErrorResponse
+		return err, false
+	}
+
+	ref := registry.Reference{Registry: target.RawReference}
+	if errResp.URL.Host != ref.Host() {
+		// raw reference is not registry host
+		var parseErr error
+		ref, parseErr = registry.ParseReference(target.RawReference)
+		if parseErr != nil {
+			// this should not happen
+			return err, false
+		}
 		if errResp.URL.Host != ref.Host() {
-			// raw reference is not registry host
-			var parseErr error
-			ref, parseErr = registry.ParseReference(target.RawReference)
-			if parseErr != nil {
-				// this should not happen
-				return err, false
-			}
-			if errResp.URL.Host != ref.Host() {
-				// not handle if the error is not from the target
-				return err, false
-			}
+			// not handle if the error is not from the target
+			return err, false
 		}
+	}
 
-		cmd.SetErrPrefix(oerrors.RegistryErrorPrefix)
-		ret := &oerrors.Error{
-			Err: oerrors.TrimErrResp(err, errResp),
-		}
+	cmd.SetErrPrefix(oerrors.RegistryErrorPrefix)
+	ret := &oerrors.Error{
+		Err: oerrors.ReportErrResp(errResp),
+	}
 
-		if ref.Registry == "docker.io" && errResp.StatusCode == http.StatusUnauthorized {
-			if ref.Repository != "" && !strings.Contains(ref.Repository, "/") {
-				// docker.io/xxx -> docker.io/library/xxx
-				ref.Repository = "library/" + ref.Repository
-				ret.Recommendation = fmt.Sprintf("Namespace seems missing. Do you mean `%s %s`?", cmd.CommandPath(), ref)
-			}
+	if ref.Registry == "docker.io" && errResp.StatusCode == http.StatusUnauthorized {
+		if ref.Repository != "" && !strings.Contains(ref.Repository, "/") {
+			// docker.io/xxx -> docker.io/library/xxx
+			ref.Repository = "library/" + ref.Repository
+			ret.Recommendation = fmt.Sprintf("Namespace seems missing. Do you mean `%s %s`?", cmd.CommandPath(), ref)
 		}
-		return ret, true
 	}
-	return err, false
+	return ret, true
 }
diff --git a/cmd/oras/root/attach.go b/cmd/oras/root/attach.go
index 719237f..76c2821 100644
--- a/cmd/oras/root/attach.go
+++ b/cmd/oras/root/attach.go
@@ -207,7 +207,8 @@ func runAttach(cmd *cobra.Command, opts *attachOptions) error {
 			}
 			return content.Successors(ctx, fetcher, node)
 		}
-		return oras.CopyGraph(ctx, store, dst, root, graphCopyOptions)
+		err := oras.CopyGraph(ctx, store, dst, root, graphCopyOptions)
+		return oerrors.UnwrapCopyError(err) // we don't need the CopyError information so we unwrap it here
 	}
 
 	// Attach
diff --git a/cmd/oras/root/cp.go b/cmd/oras/root/cp.go
index f9d1d91..c2134d9 100644
--- a/cmd/oras/root/cp.go
+++ b/cmd/oras/root/cp.go
@@ -219,6 +219,7 @@ func doCopy(ctx context.Context, copyHandler status.CopyHandler, src oras.ReadOn
 			desc, err = oras.Copy(ctx, src, opts.From.Reference, dst, opts.To.Reference, copyOptions)
 		}
 	}
+	// leave the CopyError to oerrors.Modifier for prefix processing
 	return desc, err
 }
 
diff --git a/cmd/oras/root/pull.go b/cmd/oras/root/pull.go
index 1bc44c9..cf1f683 100644
--- a/cmd/oras/root/pull.go
+++ b/cmd/oras/root/pull.go
@@ -18,7 +18,6 @@ package root
 import (
 	"context"
 	"errors"
-	"fmt"
 	"io"
 	"sync"
 
@@ -167,10 +166,14 @@ func runPull(cmd *cobra.Command, opts *pullOptions) (pullError error) {
 
 	desc, err := doPull(ctx, src, dst, copyOptions, metadataHandler, statusHandler, opts)
 	if err != nil {
-		if errors.Is(err, file.ErrPathTraversalDisallowed) {
-			err = fmt.Errorf("%s: %w", "use flag --allow-path-traversal to allow insecurely pulling files outside of working directory", err)
+		if !errors.Is(err, file.ErrPathTraversalDisallowed) {
+			return err
+		}
+		// customize friendly message for path traversal error
+		return &oerrors.Error{
+			Err:            err,
+			Recommendation: `Pulling files outside of working directory is insecure and blocked by default. If you trust the content producer, use --allow-path-traversal to bypass this check.`,
 		}
-		return err
 	}
 	metadataHandler.OnPulled(&opts.Target, desc)
 	return metadataHandler.Render()
@@ -291,7 +294,7 @@ func doPull(ctx context.Context, src oras.ReadOnlyTarget, dst oras.GraphTarget,
 
 	// Copy
 	desc, err := oras.Copy(ctx, src, po.Reference, dst, po.Reference, opts)
-	return desc, err
+	return desc, oerrors.UnwrapCopyError(err) // we don't need the CopyError information so we unwrap it here
 }
 
 func notifyOnce(notified *sync.Map, s ocispec.Descriptor, notify func(ocispec.Descriptor) error) error {
diff --git a/cmd/oras/root/push.go b/cmd/oras/root/push.go
index 3d3cf16..a2a442a 100644
--- a/cmd/oras/root/push.go
+++ b/cmd/oras/root/push.go
@@ -266,7 +266,7 @@ func runPush(cmd *cobra.Command, opts *pushOptions) error {
 		} else {
 			_, err = oras.Copy(ctx, union, root.Digest.String(), dst, tag, copyOptions)
 		}
-		return err
+		return oerrors.UnwrapCopyError(err) // we don't need the CopyError information so we unwrap it here
 	}
 
 	// Push



Test patch:
diff --git a/cmd/oras/internal/errors/errors_test.go b/cmd/oras/internal/errors/errors_test.go
index d5b3898..1cb6877 100644
--- a/cmd/oras/internal/errors/errors_test.go
+++ b/cmd/oras/internal/errors/errors_test.go
@@ -16,9 +16,13 @@ limitations under the License.
 package errors
 
 import (
+	"fmt"
+	"net/url"
 	"testing"
 
 	"github.com/spf13/pflag"
+	"oras.land/oras-go/v2"
+	"oras.land/oras-go/v2/registry/remote/errcode"
 )
 
 func TestCheckMutuallyExclusiveFlags(t *testing.T) {
@@ -92,3 +96,97 @@ func TestCheckRequiredTogetherFlags(t *testing.T) {
 		})
 	}
 }
+
+func TestReportErrResp(t *testing.T) {
+	// Test case with empty errors
+	emptyErrorsResp := &errcode.ErrorResponse{
+		Errors:     []errcode.Error{},
+		StatusCode: 401,
+		URL:        &url.URL{Host: "localhost:5000"},
+		Method:     "GET",
+	}
+
+	// Test case with non-empty errors
+	nonEmptyErrorsResp := &errcode.ErrorResponse{
+		Errors: []errcode.Error{
+			{
+				Code:    "UNAUTHORIZED",
+				Message: "authentication required",
+			},
+			{
+				Code:    "INVALID_CREDENTIALS",
+				Message: "invalid credentials provided",
+				Detail:  "please check your username and password",
+			},
+		},
+		StatusCode: 401,
+		URL:        &url.URL{Host: "localhost:5000"},
+		Method:     "GET",
+	}
+
+	tests := []struct {
+		name    string
+		errResp *errcode.ErrorResponse
+		wantErr error
+	}{
+		{
+			name:    "empty errors",
+			errResp: emptyErrorsResp,
+			wantErr: emptyErrorsResp,
+		},
+		{
+			name:    "non-empty errors",
+			errResp: nonEmptyErrorsResp,
+			wantErr: nonEmptyErrorsResp.Errors,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := ReportErrResp(tt.errResp)
+			if got.Error() != tt.wantErr.Error() {
+				t.Errorf("ReportErrResp() = %v, want %v", got, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestUnwrapCopyError(t *testing.T) {
+	// Create a regular error
+	regularErr := fmt.Errorf("regular error")
+
+	// Create an oras.CopyError with an inner error
+	innerErr := fmt.Errorf("inner error")
+	copyErr := &oras.CopyError{Err: innerErr}
+
+	tests := []struct {
+		name     string
+		inputErr error
+		wantErr  error
+	}{
+		{
+			name:     "nil error",
+			inputErr: nil,
+			wantErr:  nil,
+		},
+		{
+			name:     "regular error",
+			inputErr: regularErr,
+			wantErr:  regularErr,
+		},
+		{
+			name:     "copy error",
+			inputErr: copyErr,
+			wantErr:  innerErr,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotErr := UnwrapCopyError(tt.inputErr)
+			if gotErr != tt.wantErr {
+				t.Errorf("UnwrapCopyError() = %v, want %v", gotErr, tt.wantErr)
+			}
+		})
+	}
+}
diff --git a/cmd/oras/internal/option/binary_target_test.go b/cmd/oras/internal/option/binary_target_test.go
new file mode 100644
index 0000000..175e434
--- /dev/null
+++ b/cmd/oras/internal/option/binary_target_test.go
@@ -0,0 +1,103 @@
+/*
+Copyright The ORAS Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package option
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/spf13/cobra"
+	"oras.land/oras-go/v2"
+)
+
+func TestBinaryTarget_ModifyError(t *testing.T) {
+	testCases := []struct {
+		name         string
+		target       *BinaryTarget
+		err          error
+		wantModified bool
+		wantPrefix   string
+		wantErr      error
+	}{
+		{
+			name: "CopyError with Source origin sets prefix",
+			target: &BinaryTarget{
+				From: Target{
+					Type:         "registry",
+					RawReference: "localhost:5000/test:v1",
+				},
+				To: Target{
+					Type:         "oci-layout",
+					RawReference: "oci-dir:v1",
+				},
+			},
+			err:          &oras.CopyError{Origin: oras.CopyErrorOriginSource, Err: errors.New("source error")},
+			wantModified: true,
+			wantPrefix:   `Error from source registry for "localhost:5000/test:v1":`,
+			wantErr:      errors.New("source error"),
+		},
+		{
+			name: "CopyError with Destination origin sets prefix",
+			target: &BinaryTarget{
+				From: Target{
+					Type:         "registry",
+					RawReference: "localhost:5000/test:v1",
+				},
+				To: Target{
+					Type:         "oci-layout",
+					RawReference: "oci-dir:v1",
+				},
+			},
+			err:          &oras.CopyError{Origin: oras.CopyErrorOriginDestination, Err: errors.New("destination error")},
+			wantModified: true,
+			wantPrefix:   `Error from destination oci-layout for "oci-dir:v1":`,
+			wantErr:      errors.New("destination error"),
+		},
+		{
+			name: "CopyError with unknown origin",
+			target: &BinaryTarget{
+				From: Target{
+					Type:         "registry",
+					RawReference: "localhost:5000/test:v1",
+				},
+				To: Target{
+					Type:         "oci-layout",
+					RawReference: "oci-dir:v1",
+				},
+			},
+			err:          &oras.CopyError{Origin: oras.CopyErrorOrigin(-1), Err: errors.New("unknown error")},
+			wantPrefix:   "Error:",
+			wantModified: true,
+			wantErr:      errors.New("unknown error"),
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			cmd := &cobra.Command{}
+			err, modified := tc.target.ModifyError(cmd, tc.err)
+			if modified != tc.wantModified {
+				t.Errorf("ModifyError() modified = %v, want %v", modified, tc.wantModified)
+			}
+			if modified && cmd.ErrPrefix() != tc.wantPrefix {
+				t.Errorf("ModifyError() cmd.ErrPrefix() = %q, want %q", cmd.ErrPrefix(), tc.wantPrefix)
+			}
+			if err.Error() != tc.wantErr.Error() {
+				t.Errorf("ModifyError() error = %q, want %q", err.Error(), tc.wantErr.Error())
+			}
+		})
+	}
+}
diff --git a/cmd/oras/internal/option/target_test.go b/cmd/oras/internal/option/target_test.go
index e531132..1279d23 100644
--- a/cmd/oras/internal/option/target_test.go
+++ b/cmd/oras/internal/option/target_test.go
@@ -17,9 +17,9 @@ package option
 
 import (
 	"errors"
+	"fmt"
 	"net/http"
 	"net/url"
-	"reflect"
 	"strings"
 	"testing"
 
@@ -159,20 +159,105 @@ func Test_parseOCILayoutReference(t *testing.T) {
 	}
 }
 
-func TestTarget_Modify_ociLayout(t *testing.T) {
+func TestTarget_ModifyError_ociLayout(t *testing.T) {
 	errClient := errors.New("client error")
 	opts := &Target{}
-	got, modified := opts.Modify(&cobra.Command{}, errClient)
+	cmd := &cobra.Command{}
+	got, modified := opts.ModifyError(cmd, errClient)
 
 	if modified {
 		t.Errorf("expect error not to be modified but received true")
 	}
 	if got != errClient {
-		t.Errorf("unexpected output from Target.Process() = %v", got)
+		t.Errorf("unexpected output from Target.ModifyError() = %v", got)
+	}
+	if want := "Error:"; cmd.ErrPrefix() != want {
+		t.Errorf("unexpected error prefix set on command: %q, want %q", cmd.ErrPrefix(), want)
+	}
+}
+
+func TestTarget_ModifyError_NotFound(t *testing.T) {
+	// test errdef.ErrNotFound error returned by oci layout and remote
+	tests := []struct {
+		name            string
+		targetType      string
+		rawReference    string
+		wantErrPrefix   string
+		wantModifiedErr error
+		wantModified    bool
+		isOCILayout     bool
+	}{
+		{
+			name:          "not found",
+			targetType:    TargetTypeOCILayout,
+			rawReference:  "oci-dir:latest",
+			wantErrPrefix: "Error:",
+			wantModified:  false,
+			isOCILayout:   true,
+		},
+		{
+			name:          "remote not found",
+			targetType:    TargetTypeRemote,
+			rawReference:  "localhost:5000/test:latest",
+			wantErrPrefix: oerrors.RegistryErrorPrefix,
+			wantModified:  true,
+			isOCILayout:   false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			opts := &Target{
+				Type:         tt.targetType,
+				RawReference: tt.rawReference,
+				IsOCILayout:  tt.isOCILayout,
+			}
+			cmd := &cobra.Command{}
+			originalErr := fmt.Errorf("not found: %w", errdef.ErrNotFound)
+			got, modified := opts.ModifyError(cmd, originalErr)
+			if modified != tt.wantModified {
+				t.Errorf("Target.ModifyError() modified = %v, want %v", modified, tt.wantModified)
+			}
+			if got != originalErr {
+				t.Errorf("Target.ModifyError() got = %v, want %v", got, originalErr)
+			}
+			if cmd.ErrPrefix() != tt.wantErrPrefix {
+				t.Errorf("Target.ModifyError() cmd.ErrPrefix() = %q, want %q", cmd.ErrPrefix(), tt.wantErrPrefix)
+			}
+		})
 	}
 }
 
-func TestTarget_Modify_errInvalidReference(t *testing.T) {
+func TestTarget_ModifyError_errResponse(t *testing.T) {
+	errResp := &errcode.ErrorResponse{
+		URL:        &url.URL{Host: "localhost:5000"},
+		StatusCode: http.StatusUnauthorized,
+		Errors: errcode.Errors{
+			errcode.Error{
+				Code:    "NAME_INVALID",
+				Message: "invalid name",
+			},
+		},
+	}
+
+	opts := &Target{
+		Type:         TargetTypeRemote,
+		RawReference: "localhost:5000/test:v1",
+	}
+	cmd := &cobra.Command{}
+	got, modified := opts.ModifyError(cmd, errResp)
+
+	if !modified {
+		t.Errorf("expected error to be modified but received %v", modified)
+	}
+	if got.Error() != errResp.Errors.Error() {
+		t.Errorf("unexpected output from Target.ModifyError() = %v", got)
+	}
+	if cmd.ErrPrefix() != oerrors.RegistryErrorPrefix {
+		t.Errorf("unexpected error prefix set on command: %q, want %q", cmd.ErrPrefix(), oerrors.RegistryErrorPrefix)
+	}
+}
+
+func TestTarget_ModifyError_errInvalidReference(t *testing.T) {
 	errResp := &errcode.ErrorResponse{
 		URL:        &url.URL{Host: "registry-1.docker.io"},
 		StatusCode: http.StatusUnauthorized,
@@ -185,19 +270,24 @@ func TestTarget_Modify_errInvalidReference(t *testing.T) {
 		},
 	}
 	opts := &Target{
+		Type:         TargetTypeRemote,
 		RawReference: "invalid-reference",
 	}
-	got, modified := opts.Modify(&cobra.Command{}, errResp)
+	cmd := &cobra.Command{}
+	got, modified := opts.ModifyError(cmd, errResp)
 
 	if modified {
 		t.Errorf("expect error not to be modified but received true")
 	}
 	if got != errResp {
-		t.Errorf("unexpected output from Target.Process() = %v", got)
+		t.Errorf("unexpected output from Target.ModifyError() = %v", got)
+	}
+	if want := "Error:"; cmd.ErrPrefix() != want {
+		t.Errorf("unexpected error prefix set on command: %q, want %q", cmd.ErrPrefix(), want)
 	}
 }
 
-func TestTarget_Modify_errHostNotMatching(t *testing.T) {
+func TestTarget_ModifyError_errHostNotMatching(t *testing.T) {
 	errResp := &errcode.ErrorResponse{
 		URL:        &url.URL{Host: "registry-1.docker.io"},
 		StatusCode: http.StatusUnauthorized,
@@ -211,15 +301,20 @@ func TestTarget_Modify_errHostNotMatching(t *testing.T) {
 	}
 
 	opts := &Target{
+		Type:         TargetTypeRemote,
 		RawReference: "registry-2.docker.io/test:tag",
 	}
-	_, modified := opts.Modify(&cobra.Command{}, errResp)
+	cmd := &cobra.Command{}
+	_, modified := opts.ModifyError(cmd, errResp)
 	if modified {
 		t.Errorf("expect error not to be modified but received true")
 	}
+	if want := "Error:"; cmd.ErrPrefix() != want {
+		t.Errorf("unexpected error prefix set on command: %q, want %q", cmd.ErrPrefix(), want)
+	}
 }
 
-func TestTarget_Modify_dockerHint(t *testing.T) {
+func TestTarget_ModifyError_dockerHint(t *testing.T) {
 	type fields struct {
 		Remote       Remote
 		RawReference string
@@ -243,7 +338,10 @@ func TestTarget_Modify_dockerHint(t *testing.T) {
 	}{
 		{
 			"namespace already exists",
-			fields{RawReference: "docker.io/library/alpine:latest"},
+			fields{
+				Type:         TargetTypeRemote,
+				RawReference: "docker.io/library/alpine:latest",
+			},
 			&errcode.ErrorResponse{
 				URL:        &url.URL{Host: "registry-1.docker.io"},
 				StatusCode: http.StatusUnauthorized,
@@ -253,7 +351,10 @@ func TestTarget_Modify_dockerHint(t *testing.T) {
 		},
 		{
 			"no namespace",
-			fields{RawReference: "docker.io"},
+			fields{
+				Type:         TargetTypeRemote,
+				RawReference: "docker.io",
+			},
 			&errcode.ErrorResponse{
 				URL:        &url.URL{Host: "registry-1.docker.io"},
 				StatusCode: http.StatusUnauthorized,
@@ -263,7 +364,10 @@ func TestTarget_Modify_dockerHint(t *testing.T) {
 		},
 		{
 			"not 401",
-			fields{RawReference: "docker.io"},
+			fields{
+				Type:         TargetTypeRemote,
+				RawReference: "docker.io",
+			},
 			&errcode.ErrorResponse{
 				URL:        &url.URL{Host: "registry-1.docker.io"},
 				StatusCode: http.StatusConflict,
@@ -274,6 +378,7 @@ func TestTarget_Modify_dockerHint(t *testing.T) {
 		{
 			"should hint",
 			fields{
+				Type:         TargetTypeRemote,
 				RawReference: "docker.io/alpine",
 				Path:         "oras test",
 			},
@@ -300,13 +405,13 @@ func TestTarget_Modify_dockerHint(t *testing.T) {
 				Path:         tt.fields.Path,
 				IsOCILayout:  tt.fields.IsOCILayout,
 			}
-			got, modified := opts.Modify(cmd, tt.err)
+			got, modified := opts.ModifyError(cmd, tt.err)
 			gotErr, ok := got.(*oerrors.Error)
 			if !ok {
 				t.Errorf("expecting error to be *oerrors.Error but received %T", got)
 			}
-			if !reflect.DeepEqual(gotErr.Err, tt.modifiedErr.Err) || gotErr.Usage != tt.modifiedErr.Usage || gotErr.Recommendation != tt.modifiedErr.Recommendation {
-				t.Errorf("Target.Modify() error = %v, wantErr %v", gotErr, tt.modifiedErr)
+			if gotErr.Err.Error() != tt.modifiedErr.Err.Error() || gotErr.Usage != tt.modifiedErr.Usage || gotErr.Recommendation != tt.modifiedErr.Recommendation {
+				t.Errorf("Target.ModifyError() error = %v, wantErr %v", gotErr, tt.modifiedErr)
 			}
 			if !modified {
 				t.Errorf("Failed to modify %v", tt.err)
diff --git a/test/e2e/internal/utils/const.go b/test/e2e/internal/utils/const.go
index 4ea0940..8e68760 100644
--- a/test/e2e/internal/utils/const.go
+++ b/test/e2e/internal/utils/const.go
@@ -34,6 +34,5 @@ var (
 		"--image-spec",
 	}
 	RegistryErrorPrefix = "Error response from registry: "
-	EmptyBodyPrefix     = "recognizable error message not found: "
 	InvalidTag          = "i-dont-think-this-tag-exists"
 )
diff --git a/test/e2e/suite/auth/auth.go b/test/e2e/suite/auth/auth.go
index 2772e54..00a89a2 100644
--- a/test/e2e/suite/auth/auth.go
+++ b/test/e2e/suite/auth/auth.go
@@ -35,7 +35,6 @@ var _ = Describe("Common registry user", func() {
 		It("should run commands without logging in", func() {
 			authConfigPath := filepath.Join(GinkgoT().TempDir(), "auth.config")
 			RunWithoutLogin("attach", ZOTHost+"/repo:tag", "-a", "test=true", "--artifact-type", "doc/example", "--registry-config", authConfigPath)
-			RunWithoutLogin("copy", ZOTHost+"/repo:from", ZOTHost+"/repo:to", "--from-registry-config", authConfigPath, "--to-registry-config", authConfigPath)
 			RunWithoutLogin("discover", ZOTHost+"/repo:tag", "--registry-config", authConfigPath)
 			RunWithoutLogin("push", "-a", "key=value", ZOTHost+"/repo:tag", "--registry-config", authConfigPath)
 			RunWithoutLogin("pull", ZOTHost+"/repo:tag", "--registry-config", authConfigPath)
@@ -48,14 +47,21 @@ var _ = Describe("Common registry user", func() {
 			RunWithoutLogin("repo", "tags", RegistryRef(ZOTHost, "repo", ""), "--registry-config", authConfigPath)
 			RunWithoutLogin("manifest", "fetch-config", ZOTHost+"/repo:tag", "--registry-config", authConfigPath)
 		})
+
+		It("should fail with modified error prefix when running commands without logging in", func() {
+			authConfigPath := filepath.Join(GinkgoT().TempDir(), "auth.config")
+			ORAS("copy", ZOTHost+"/repo:from", ZOTHost+"/repo:to", "--from-registry-config", authConfigPath, "--to-registry-config", authConfigPath).
+				ExpectFailure().
+				MatchErrKeyWords(fmt.Sprintf("Error from source registry for %q", ZOTHost+"/repo:from"), "basic credential not found").
+				WithDescription("fail with modified error prefix when not logged in").
+				Exec()
+		})
 	})
 
 	When("credential is invalid", func() {
 		It("should fail with registry error", func() {
 			RunWithInvalidCreds("attach", ZOTHost+"/repo:tag", "-a", "test=true", "--artifact-type", "doc/example")
 			RunWithInvalidCreds("discover", ZOTHost+"/repo:tag")
-			RunWithInvalidCreds("push", "-a", "key=value", ZOTHost+"/repo:tag")
-			RunWithInvalidCreds("pull", ZOTHost+"/repo:tag")
 			RunWithInvalidCreds("manifest", "fetch", ZOTHost+"/repo:tag")
 			RunWithInvalidCreds("blob", "delete", ZOTHost+"/repo@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
 			RunWithInvalidCreds("blob", "push", ZOTHost+"/repo", WriteTempFile("blob", "test"))
diff --git a/test/e2e/suite/command/cp.go b/test/e2e/suite/command/cp.go
index 2c5812a..43cedcc 100644
--- a/test/e2e/suite/command/cp.go
+++ b/test/e2e/suite/command/cp.go
@@ -125,7 +125,8 @@ var _ = Describe("ORAS beginners:", func() {
 			src := PrepareTempOCI(ArtifactRepo)
 			dst := RegistryRef(ZOTHost, cpTestRepo("dest-not-logged-in"), "")
 			ORAS("cp", Flags.FromLayout, LayoutRef(src, foobar.Tag), dst, "--to-username", Username, "--to-password", Password+"?").
-				MatchErrKeyWords(RegistryErrorPrefix).ExpectFailure().Exec()
+				MatchErrKeyWords(fmt.Sprintf("Error from destination registry for %q", dst)).
+				ExpectFailure().Exec()
 		})
 
 		It("should fail and show registry error prefix if source registry is not logged in", func() {
diff --git a/test/e2e/suite/command/resolve.go b/test/e2e/suite/command/resolve.go
index 58d775b..97eb302 100644
--- a/test/e2e/suite/command/resolve.go
+++ b/test/e2e/suite/command/resolve.go
@@ -52,7 +52,7 @@ var _ = Describe("ORAS beginners:", func() {
 		It("should fail with empty response when returned response doesn't have body", func() {
 			ORAS("resolve", RegistryRef(ZOTHost, ImageRepo, InvalidTag), "-u", Username, "-p", Password+"1").
 				ExpectFailure().
-				MatchErrKeyWords(RegistryErrorPrefix, EmptyBodyPrefix, "response status code 401: Unauthorized").
+				MatchErrKeyWords(RegistryErrorPrefix, "response status code 401: Unauthorized").
 				Exec()
 		})