Skip to content

Commit b07ac83

Browse files
committed
feat(#3996): classify generated-media save failures into safe user-visible reasons
Replace the generic "see debug log for details" materialization and manifest warnings with fixed, classified sentences: no session workspace, unwritable/read-only location, deleted root, filename collision exhaustion, refused save path, and a saved-but-unrecorded manifest failure that may not render inline. Unclassified causes tell the user to enable --debug and retry. Every reason is a constant, so no absolute path, session ID, provider metadata, or raw OS error can leak into a WarningEvent; the detailed error still reaches the debug log only. Add workspacemedia.ErrNameExhausted so collision exhaustion is matchable without echoing the requested path.
1 parent b1ee699 commit b07ac83

6 files changed

Lines changed: 234 additions & 9 deletions

File tree

pkg/runtime/loop.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,9 +1337,11 @@ func sanitizeToolCallName(name string) string {
13371337
// error (including the workspace root) to the debug log only, and emits a
13381338
// runtime [WarningEvent] carrying nothing but safe display metadata — the
13391339
// exact 1-based failed item index and total batch count, the sanitized MIME
1340-
// type, and the sanitized provider-supplied name (or [fallbackDisplayName]
1340+
// type, the sanitized provider-supplied name (or [fallbackDisplayName]
13411341
// when that name is empty, whitespace-only, or missing — never omitted,
1342-
// exactly like the strip_generated_media.go placeholder) — so the failure
1342+
// exactly like the strip_generated_media.go placeholder), and a fixed
1343+
// classified reason from [mediaSaveFailureReason] (a retry-with-debug
1344+
// hint when the cause is unclassified, never raw error text) — so the failure
13431345
// is observable to the user/caller without leaking the absolute workspace
13441346
// path or a raw OS error (which could contain that path) into a surface a
13451347
// user might paste into a bug report or share screen. Both the name AND the
@@ -1374,8 +1376,8 @@ func (r *LocalRuntime) materializeGeneratedMedia(ctx context.Context, sess *sess
13741376
if displayName == "" {
13751377
displayName = fallbackDisplayName
13761378
}
1377-
warning := fmt.Sprintf("Failed to save generated media item %d/%d (%s, %s); see debug log for details",
1378-
i+1, len(media), safeMimeType, displayName)
1379+
warning := fmt.Sprintf("Failed to save generated media item %d/%d (%s, %s). %s",
1380+
i+1, len(media), safeMimeType, displayName, mediaSaveFailureReason(err))
13791381
events.Emit(Warning(chat.TruncateUTF8Bytes(warning, maxPlaceholderOrWarningBytes), agentName))
13801382
}
13811383

@@ -1410,7 +1412,7 @@ func (r *LocalRuntime) materializeGeneratedMedia(ctx context.Context, sess *sess
14101412
slog.DebugContext(ctx, "Failed to record generated media in the manifest; the file was written but may not display inline",
14111413
"agent", agentName, "session_id", sess.ID, "rel_path", res.RelPath, "error", err)
14121414
if events != nil {
1413-
warning := fmt.Sprintf("Saved generated media %s but failed to record it for display; see debug log for details", res.RelPath)
1415+
warning := fmt.Sprintf("Saved generated media %s but could not record it for display; it may not render inline. %s", res.RelPath, retryWithDebugAdvice)
14141416
events.Emit(Warning(chat.TruncateUTF8Bytes(warning, maxPlaceholderOrWarningBytes), agentName))
14151417
}
14161418
}

pkg/runtime/materialize_generated_media_test.go

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package runtime
33
import (
44
"bytes"
55
"errors"
6+
"fmt"
67
"io/fs"
78
"log/slog"
89
"os"
910
"path/filepath"
1011
"strings"
12+
"syscall"
1113
"testing"
1214
"time"
1315

@@ -268,6 +270,8 @@ func TestMaterializeGeneratedMedia_NoWorkspaceRoot(t *testing.T) {
268270
assert.Contains(t, warnings[1].Message, "2/2")
269271
assert.Contains(t, warnings[1].Message, "dog.jpg")
270272
for _, w := range warnings {
273+
assert.Contains(t, w.Message, "No session workspace is available to save into.",
274+
"a missing workspace must surface its classified reason")
271275
assertSafeWarningMessage(t, w.Message, sess.ID, "")
272276
}
273277

@@ -298,6 +302,8 @@ func TestMaterializeGeneratedMedia_UnwritableRoot(t *testing.T) {
298302
require.Len(t, warnings, 1)
299303
assert.Contains(t, warnings[0].Message, "cat.png")
300304
assert.Contains(t, warnings[0].Message, "1/1")
305+
assert.Contains(t, warnings[0].Message, "The save location no longer exists.",
306+
"a deleted workspace root must surface its classified reason")
301307
assertSafeWarningMessage(t, warnings[0].Message, sess.ID, root)
302308
assertNoFilesUnder(t, dataDir)
303309
}
@@ -334,6 +340,9 @@ func TestMaterializeGeneratedMedia_PartialSuccess_SingleBatchCall(t *testing.T)
334340
assert.Contains(t, warnings[0].Message, "2/2", "the failing item's index/total must reflect its real position in the batch")
335341
assert.Contains(t, warnings[0].Message, "dog.jpg")
336342
assert.Contains(t, warnings[0].Message, "image/jpeg")
343+
assert.Contains(t, warnings[0].Message, retryWithDebugAdvice,
344+
"an unclassified failure must carry the retry-with-debug advice")
345+
assert.NotContains(t, warnings[0].Message, "injected failure", "the raw error text must never reach the warning")
337346
assertSafeWarningMessage(t, warnings[0].Message, sess.ID, root)
338347

339348
data, err := os.ReadFile(filepath.Join(root, "cat.png"))
@@ -346,6 +355,82 @@ func TestMaterializeGeneratedMedia_PartialSuccess_SingleBatchCall(t *testing.T)
346355
require.ErrorIs(t, err, session.ErrGeneratedFileNotFound, "a failed write must never be recorded in the manifest")
347356
}
348357

358+
// TestMaterializeGeneratedMedia_ClassifiedWriteFailureReasons drives every
359+
// classified writer-failure category through the workspacemediaWrite seam
360+
// and proves the per-item warning carries exactly the fixed classified
361+
// sentence — while the raw error (with its embedded secret path) reaches
362+
// only the debug log, never the warning.
363+
func TestMaterializeGeneratedMedia_ClassifiedWriteFailureReasons(t *testing.T) {
364+
const secretPath = "/secret/root/cat.png"
365+
366+
cases := []struct {
367+
name string
368+
writeErr error
369+
wantReason string
370+
}{
371+
{
372+
name: "not writable",
373+
writeErr: fmt.Errorf("claim %q: %w", secretPath, fs.ErrPermission),
374+
wantReason: "The save location is not writable.",
375+
},
376+
{
377+
name: "read-only filesystem",
378+
writeErr: fmt.Errorf("open workspace root: %w", &fs.PathError{Op: "open", Path: secretPath, Err: syscall.EROFS}),
379+
wantReason: "The save location is not writable.",
380+
},
381+
{
382+
name: "collision exhaustion",
383+
writeErr: fmt.Errorf("%w: %q after 10000 attempts", workspacemedia.ErrNameExhausted, secretPath),
384+
wantReason: "Every candidate filename is already taken.",
385+
},
386+
{
387+
// The provider-named flow retries ErrPathEscape once under the
388+
// generic name; the seam fails both attempts, so the refusal
389+
// itself must reach the user as the classified reason.
390+
name: "requested path refused",
391+
writeErr: fmt.Errorf("%w: %q: absolute path", workspacemedia.ErrPathEscape, secretPath),
392+
wantReason: "The requested save path was refused.",
393+
},
394+
}
395+
396+
for _, tc := range cases {
397+
t.Run(tc.name, func(t *testing.T) {
398+
r, _, dataDir := newMediaTestRuntime(t)
399+
sess, root := workspaceSession(t, "sess-classified-"+tc.name)
400+
401+
var logBuf bytes.Buffer
402+
prevLogger := slog.Default()
403+
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})))
404+
t.Cleanup(func() { slog.SetDefault(prevLogger) })
405+
406+
orig := workspacemediaWrite
407+
workspacemediaWrite = func(string, string, []byte, string) (workspacemedia.Result, error) {
408+
return workspacemedia.Result{}, tc.writeErr
409+
}
410+
t.Cleanup(func() { workspacemediaWrite = orig })
411+
412+
sink := &collectingSink{}
413+
parts := r.materializeGeneratedMedia(t.Context(), sess, []chat.MediaDelta{
414+
{Data: []byte{0x01}, MimeType: "image/png", Name: "cat.png", Size: 1},
415+
}, "root", sink)
416+
417+
assert.Empty(t, parts)
418+
warnings := sink.warnings()
419+
require.Len(t, warnings, 1)
420+
msg := warnings[0].Message
421+
assert.Contains(t, msg, "1/1")
422+
assert.Contains(t, msg, "cat.png")
423+
assert.Contains(t, msg, tc.wantReason)
424+
assert.NotContains(t, msg, retryWithDebugAdvice, "a classified failure must show its reason, not the debug fallback")
425+
assert.NotContains(t, msg, secretPath, "the warning must never leak a path embedded in the error")
426+
assertSafeWarningMessage(t, msg, sess.ID, root)
427+
428+
assert.Contains(t, logBuf.String(), secretPath, "the detailed error must still reach the debug log")
429+
assertNoFilesUnder(t, dataDir)
430+
})
431+
}
432+
}
433+
349434
// storeWithoutManifest hides the built-in store's GeneratedMediaManifest
350435
// implementation: interface embedding only promotes session.Store's own
351436
// method set, so the type assertion in recordGeneratedFile fails.
@@ -372,7 +457,10 @@ func TestMaterializeGeneratedMedia_ManifestFailureKeepsFileAndWarns(t *testing.T
372457
warnings := sink.warnings()
373458
require.Len(t, warnings, 1)
374459
assert.Contains(t, warnings[0].Message, "cat.png")
375-
assert.Contains(t, warnings[0].Message, "record")
460+
assert.Contains(t, warnings[0].Message, "could not record it for display")
461+
assert.Contains(t, warnings[0].Message, retryWithDebugAdvice,
462+
"the manifest cause is unclassified storage internals, so the warning must carry the retry-with-debug advice")
463+
assert.NotContains(t, warnings[0].Message, "see debug log")
376464
assertBoundedSingleLineUTF8(t, warnings[0].Message)
377465
}
378466

pkg/runtime/media_save_failure.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package runtime
2+
3+
import (
4+
"errors"
5+
"io/fs"
6+
"syscall"
7+
8+
"github.com/docker/docker-agent/pkg/session"
9+
"github.com/docker/docker-agent/pkg/workspacemedia"
10+
)
11+
12+
// retryWithDebugAdvice is the user-facing fallback for a generated-media
13+
// save failure with no safe classified reason: it tells the user how to
14+
// capture the technical details instead of leaking any of them.
15+
const retryWithDebugAdvice = "Enable --debug and retry to capture technical details."
16+
17+
// mediaSaveFailureReason maps a generated-media save failure to a fixed,
18+
// user-safe sentence for the runtime WarningEvent. Every return value is a
19+
// constant: nothing from err — which may embed the absolute workspace
20+
// root, a requested path, a session ID, or raw OS error text — ever
21+
// reaches the warning. The detailed error belongs in the debug log only.
22+
func mediaSaveFailureReason(err error) string {
23+
switch {
24+
case errors.Is(err, session.ErrWorkingDirUnavailable):
25+
return "No session workspace is available to save into."
26+
case errors.Is(err, workspacemedia.ErrNameExhausted):
27+
return "Every candidate filename is already taken."
28+
case errors.Is(err, workspacemedia.ErrPathEscape):
29+
return "The requested save path was refused."
30+
case errors.Is(err, fs.ErrPermission), errors.Is(err, syscall.EROFS):
31+
return "The save location is not writable."
32+
case errors.Is(err, fs.ErrNotExist):
33+
return "The save location no longer exists."
34+
default:
35+
return retryWithDebugAdvice
36+
}
37+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package runtime
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io/fs"
7+
"syscall"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
12+
"github.com/docker/docker-agent/pkg/session"
13+
"github.com/docker/docker-agent/pkg/workspacemedia"
14+
)
15+
16+
// TestMediaSaveFailureReason proves every classified failure maps to its
17+
// fixed sentence, that anything unclassified falls back to the
18+
// retry-with-debug advice, and — the redaction contract — that no fragment
19+
// of the underlying error (absolute paths, session IDs, raw OS error text)
20+
// ever survives into the returned reason.
21+
func TestMediaSaveFailureReason(t *testing.T) {
22+
t.Parallel()
23+
24+
const (
25+
secretRoot = "/Users/someone/secret-workspace"
26+
sessionID = "sess-1234-secret"
27+
)
28+
29+
cases := []struct {
30+
name string
31+
err error
32+
want string
33+
}{
34+
{
35+
name: "no workspace provenance",
36+
err: fmt.Errorf("%w: session %s has no workspace root and no parent", session.ErrWorkingDirUnavailable, sessionID),
37+
want: "No session workspace is available to save into.",
38+
},
39+
{
40+
name: "filename collision exhaustion",
41+
err: fmt.Errorf("%w: %q after 10000 attempts", workspacemedia.ErrNameExhausted, secretRoot+"/cat.png"),
42+
want: "Every candidate filename is already taken.",
43+
},
44+
{
45+
name: "requested path refused",
46+
err: fmt.Errorf("%w: %q: absolute path", workspacemedia.ErrPathEscape, secretRoot),
47+
want: "The requested save path was refused.",
48+
},
49+
{
50+
name: "permission denied",
51+
err: &fs.PathError{Op: "open", Path: secretRoot, Err: fs.ErrPermission},
52+
want: "The save location is not writable.",
53+
},
54+
{
55+
name: "read-only filesystem",
56+
err: &fs.PathError{Op: "open", Path: secretRoot, Err: syscall.EROFS},
57+
want: "The save location is not writable.",
58+
},
59+
{
60+
name: "workspace root gone",
61+
err: fmt.Errorf("open workspace root: %w", &fs.PathError{Op: "open", Path: secretRoot, Err: fs.ErrNotExist}),
62+
want: "The save location no longer exists.",
63+
},
64+
{
65+
name: "unclassified error",
66+
err: errors.New("write " + secretRoot + "/tmp-1: device timeout for " + sessionID),
67+
want: retryWithDebugAdvice,
68+
},
69+
}
70+
71+
for _, tc := range cases {
72+
t.Run(tc.name, func(t *testing.T) {
73+
t.Parallel()
74+
got := mediaSaveFailureReason(tc.err)
75+
assert.Equal(t, tc.want, got)
76+
assert.NotContains(t, got, secretRoot, "the reason must never echo a path from the error")
77+
assert.NotContains(t, got, sessionID, "the reason must never echo a session ID from the error")
78+
assert.NotContains(t, got, tc.err.Error(), "the reason must never echo the raw error text")
79+
})
80+
}
81+
}

pkg/workspacemedia/writer.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,20 @@ type Result struct {
6363
}
6464

6565
// maxNameAttempts bounds the dash-suffix collision retry so a pathological
66-
// directory cannot loop forever; exhaustion surfaces as a visible error.
66+
// directory cannot loop forever; exhaustion surfaces as [ErrNameExhausted].
6767
const maxNameAttempts = 10000
6868

69+
// ErrNameExhausted classifies collision-suffix exhaustion: every candidate
70+
// name up to maxNameAttempts already exists. Match with errors.Is when the
71+
// failure must be explained without echoing the requested path.
72+
var ErrNameExhausted = errors.New("no free filename after exhausting collision suffixes")
73+
6974
// Write stores data under workspaceRoot at requestedPath, sanitized and
7075
// collision-avoided per the package contract, and returns the exact
7176
// workspace-relative path written. Prompt-directed subdirectories in
7277
// requestedPath are created as needed. A rejected path returns an error
73-
// matching [ErrPathEscape]; any other failure (unwritable directory, full
78+
// matching [ErrPathEscape], collision-suffix exhaustion one matching
79+
// [ErrNameExhausted]; any other failure (unwritable directory, full
7480
// disk, ...) is returned as-is for the caller to surface.
7581
func Write(workspaceRoot, requestedPath string, data []byte, mimeType string) (Result, error) {
7682
return write(workspaceRoot, requestedPath, bytes.NewReader(data), mimeType)
@@ -146,7 +152,7 @@ func claimAndPublish(root *os.Root, dir, base, ext string, r io.Reader) (string,
146152
}
147153
return rel, nil
148154
}
149-
return "", fmt.Errorf("no free name for %q after %d attempts", path.Join(dir, base+ext), maxNameAttempts)
155+
return "", fmt.Errorf("%w: %q after %d attempts", ErrNameExhausted, path.Join(dir, base+ext), maxNameAttempts)
150156
}
151157

152158
// publish writes r to a sibling temp file, syncs it, and renames it over

pkg/workspacemedia/writer_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,17 @@ func TestWrite_CollisionAfterExtensionCorrection(t *testing.T) {
227227
assert.True(t, res.ExtensionCorrected)
228228
}
229229

230+
func TestWrite_CollisionExhaustionReturnsErrNameExhausted(t *testing.T) {
231+
root := t.TempDir()
232+
require.NoError(t, os.WriteFile(filepath.Join(root, "pic.png"), []byte("x"), 0o644))
233+
for n := 1; n < maxNameAttempts; n++ {
234+
require.NoError(t, os.WriteFile(filepath.Join(root, fmt.Sprintf("pic-%d.png", n)), []byte("x"), 0o644))
235+
}
236+
237+
_, err := Write(root, "pic.png", pngData, "image/png")
238+
require.ErrorIs(t, err, ErrNameExhausted)
239+
}
240+
230241
func TestWrite_ConcurrentSameNameWriters(t *testing.T) {
231242
root := t.TempDir()
232243
const writers = 16

0 commit comments

Comments
 (0)