Files
goclaw/internal/tools/read_audio_resolve_test.go
Duc Nguyenandntduc bb7712a9ff fix(collaboration): harden delegated task isolation (#1486)
* feat(collaboration): isolate delegated artifacts and child runs

Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution.

* fix(collaboration): harden delegated task isolation

Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces.

* fix(collaboration): recover interrupted async tasks

* fix(collaboration): normalize persisted child-run status

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
2026-07-30 14:17:40 +07:00

341 lines
11 KiB
Go

package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
type testMediaPathLoader struct {
path string
root string
}
func (l testMediaPathLoader) LoadPath(string) (string, error) {
return l.path, nil
}
func (l testMediaPathLoader) MediaRootPath() string {
return l.root
}
type readAudioUnsupportedProvider struct {
name string
chatCalls int
images int
}
func (p *readAudioUnsupportedProvider) Name() string { return p.name }
func (p *readAudioUnsupportedProvider) DefaultModel() string { return "claude-3-5-sonnet-latest" }
func (p *readAudioUnsupportedProvider) Chat(_ context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
p.chatCalls++
for _, msg := range req.Messages {
p.images += len(msg.Images)
}
return &providers.ChatResponse{Content: "unexpected"}, nil
}
func (p *readAudioUnsupportedProvider) ChatStream(_ context.Context, _ providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) {
return nil, nil
}
// TestReadAudioCallProvider_TranscriptionModelWithoutCreds_FailsFast asserts
// that when no API credentials are present, a transcription-named model
// returns a clear error rather than silently falling back to chat/completions
// (which would then explode in a confusing way for transcription-only setups).
func TestReadAudioCallProvider_TranscriptionModelWithoutCreds_FailsFast(t *testing.T) {
tool := &ReadAudioTool{}
params := map[string]any{
"_provider_type": "openai",
"data": []byte{0x00, 0x01},
"mime": "audio/mpeg",
}
_, _, err := tool.callProvider(context.Background(), nil, "openai", "gpt-4o-mini-transcribe", params)
if err == nil {
t.Fatalf("expected fail-fast error for transcription model with nil credentials, got nil")
}
if !strings.Contains(strings.ToLower(err.Error()), "credential") {
t.Errorf("expected error to mention credentials, got: %v", err)
}
}
// TestReadAudioCallProvider_TranscriptionModelWithoutCreds_OpenAICompat_FailsFast
// covers the openai_compat ptype variant — the bug the original PR found:
// previously a transcription model under openai_compat fell through to the
// generic chat-API fallback because only ptype=="openai" entered the
// transcription branch.
func TestReadAudioCallProvider_TranscriptionModelWithoutCreds_OpenAICompat_FailsFast(t *testing.T) {
tool := &ReadAudioTool{}
params := map[string]any{
"_provider_type": "openai_compat",
"data": []byte{0x00, 0x01},
"mime": "audio/mpeg",
}
_, _, err := tool.callProvider(context.Background(), nil, "dashscope", "whisper-1", params)
if err == nil {
t.Fatalf("expected fail-fast error for transcription model with nil credentials (openai_compat), got nil")
}
if !strings.Contains(strings.ToLower(err.Error()), "credential") {
t.Errorf("expected error to mention credentials, got: %v", err)
}
}
// TestReadAudioCallProvider_GeminiWithoutCreds_FailsFast preserves the existing
// gemini fail-fast behavior (was previously a soft log + fallback that would
// then NPE on the registry path in tests; the broader guard makes it explicit).
func TestReadAudioCallProvider_GeminiWithoutCreds_FailsFast(t *testing.T) {
tool := &ReadAudioTool{}
params := map[string]any{
"_provider_type": "gemini",
"data": []byte{0x00, 0x01},
"mime": "audio/mpeg",
}
_, _, err := tool.callProvider(context.Background(), nil, "gemini", "gemini-2.5-flash", params)
if err == nil {
t.Fatalf("expected fail-fast error for gemini with nil credentials, got nil")
}
if !strings.Contains(strings.ToLower(err.Error()), "credential") {
t.Errorf("expected error to mention credentials, got: %v", err)
}
}
func TestReadAudioCallProvider_UnsupportedProviderDoesNotSendAudioAsImage(t *testing.T) {
reg := providers.NewRegistry(nil)
fake := &readAudioUnsupportedProvider{name: "anthropic"}
reg.Register(fake)
tool := &ReadAudioTool{registry: reg}
params := map[string]any{
"_provider_type": "anthropic",
"data": []byte{0x4f, 0x67, 0x67, 0x53},
"mime": "audio/ogg; codecs=opus",
}
_, _, err := tool.callProvider(context.Background(), nil, "anthropic", "claude-3-5-sonnet-latest", params)
if err == nil {
t.Fatalf("expected unsupported audio route error, got nil")
}
msg := strings.ToLower(err.Error())
if !strings.Contains(msg, "unsupported audio route") {
t.Fatalf("expected unsupported audio route error, got: %v", err)
}
if !strings.Contains(msg, "anthropic") || !strings.Contains(msg, "claude-3-5-sonnet-latest") {
t.Fatalf("expected provider and model in error, got: %v", err)
}
if fake.chatCalls != 0 || fake.images != 0 {
t.Fatalf("unsupported audio route called chat fallback: calls=%d images=%d", fake.chatCalls, fake.images)
}
}
func TestResolveAudioFileRequiresExactMediaID(t *testing.T) {
workspace := t.TempDir()
audioPath := filepath.Join(workspace, ".uploads", "recording.mp3")
if err := os.MkdirAll(filepath.Dir(audioPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
Path: audioPath,
MimeType: "audio/mpeg",
}
tool := NewReadAudioTool(nil, nil)
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
if gotPath, _, err := tool.resolveAudioFile(ctx, "recording.mp3"); err == nil {
t.Fatalf("non-ID value resolved to %q, want exact media_id error", gotPath)
}
gotPath, gotMime, err := tool.resolveAudioFile(ctx, ref.ID)
if err != nil {
t.Fatalf("exact media_id returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(audioPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath || gotMime != "audio/mpeg" {
t.Fatalf("resolved (%q, %q), want (%q, audio/mpeg)", gotPath, gotMime, wantPath)
}
}
func TestResolveAudioFileOmittedMediaIDUsesNewestRef(t *testing.T) {
workspace := t.TempDir()
oldPath := filepath.Join(workspace, ".uploads", "old.mp3")
latestPath := filepath.Join(workspace, ".uploads", "latest.mp3")
if err := os.MkdirAll(filepath.Dir(oldPath), 0o755); err != nil {
t.Fatal(err)
}
for _, path := range []string{oldPath, latestPath} {
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
}
refs := []providers.MediaRef{
{ID: uuid.NewString(), Kind: "audio", Path: oldPath, MimeType: "audio/mpeg"},
{ID: uuid.NewString(), Kind: "audio", Path: latestPath, MimeType: "audio/mpeg"},
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, refs)
gotPath, _, err := NewReadAudioTool(nil, nil).resolveAudioFile(ctx, "")
if err != nil {
t.Fatalf("omitted media_id returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(latestPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want newest %q", gotPath, wantPath)
}
}
func TestResolveAudioFileRejectsRefPathOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
outsidePath := filepath.Join(t.TempDir(), "secret.mp3")
if err := os.WriteFile(outsidePath, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
Path: outsidePath,
MimeType: "audio/mpeg",
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadAudioTool(nil, nil).resolveAudioFile(ctx, ref.ID)
if err == nil {
t.Fatalf("outside ref path resolved to %q, want containment error", gotPath)
}
}
func TestResolveAudioFileRejectsLegacyLoaderPathOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
outsidePath := filepath.Join(t.TempDir(), "legacy.mp3")
if err := os.WriteFile(outsidePath, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
MimeType: "audio/mpeg",
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadAudioTool(nil, testMediaPathLoader{path: outsidePath}).resolveAudioFile(ctx, ref.ID)
if err == nil {
t.Fatalf("outside legacy loader path resolved to %q, want containment error", gotPath)
}
}
func TestResolveAudioFileAcceptsConfiguredLegacyMediaRoot(t *testing.T) {
workspace := t.TempDir()
mediaRoot := filepath.Join(t.TempDir(), ".media")
audioPath := filepath.Join(mediaRoot, "session-hash", "legacy.mp3")
if err := os.MkdirAll(filepath.Dir(audioPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
MimeType: "audio/mpeg",
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadAudioTool(nil, testMediaPathLoader{
path: audioPath,
root: mediaRoot,
}).resolveAudioFile(ctx, ref.ID)
if err != nil {
t.Fatalf("configured legacy media path returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(audioPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want %q", gotPath, wantPath)
}
}
func TestResolveAudioFileAcceptsLegacyLoaderPathInsideWorkspace(t *testing.T) {
workspace := t.TempDir()
audioPath := filepath.Join(workspace, ".uploads", "legacy.mp3")
if err := os.MkdirAll(filepath.Dir(audioPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
MimeType: "audio/mpeg",
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadAudioTool(nil, testMediaPathLoader{path: audioPath}).resolveAudioFile(ctx, ref.ID)
if err != nil {
t.Fatalf("inside legacy loader path returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(audioPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want %q", gotPath, wantPath)
}
}
func TestResolveAudioFileAcceptsDelegationInputRef(t *testing.T) {
ctx, inputs, _ := delegationArtifactToolContext(t)
audioPath := filepath.Join(inputs, "recording.mp3")
if err := os.WriteFile(audioPath, []byte("audio"), 0o440); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
Path: audioPath,
MimeType: "audio/mpeg",
}
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadAudioTool(nil, nil).resolveAudioFile(ctx, ref.ID)
if err != nil {
t.Fatalf("delegation input ref returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(audioPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want staged input %q", gotPath, wantPath)
}
}