mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ebf5a6de | ||
|
|
88ec97d563 |
No files matched your search
@@ -413,8 +413,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// collected and a non-nil stop error when the run must end now.
|
||||
recoverStreamError := func(collected zeroruntime.CollectedStream) (zeroruntime.CollectedStream, error) {
|
||||
if isImageRejectionError(errors.New(collected.Error)) {
|
||||
cause := &zeroruntime.StreamError{Message: collected.Error, StatusCode: collected.ErrorStatusCode, Cause: collected.ErrorCause}
|
||||
return collected, fmt.Errorf("model %s rejected the image: %w. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, cause)
|
||||
return collected, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
|
||||
}
|
||||
// REACTIVE compaction: the streamed error may also be a context limit
|
||||
// (some providers surface it mid-stream). Compact and retry once.
|
||||
@@ -543,7 +542,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
if collected.Error != "" {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, &zeroruntime.StreamError{Message: collected.Error, StatusCode: collected.ErrorStatusCode, Cause: collected.ErrorCause}
|
||||
return result, errors.New(collected.Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4205,86 +4205,6 @@ func TestRunNilTraceForwardsUsage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunReturnsStreamErrorWithStatusCodeAndCause covers the collector-to-agent
|
||||
// propagation boundary: a provider stream that terminates with a StreamEventError
|
||||
// carrying StatusCode/Cause must have Run return an error that errors.As can
|
||||
// still recover those fields from — the detail a session's "error" event needs
|
||||
// to actually diagnose why a provider call failed (#674).
|
||||
func TestRunReturnsStreamErrorWithStatusCodeAndCause(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: "provider error: provider returned error",
|
||||
StatusCode: 500,
|
||||
Cause: "upstream returned an empty completion",
|
||||
},
|
||||
}}}
|
||||
|
||||
_, err := Run(context.Background(), "hi", provider, Options{
|
||||
SessionID: "stream-error-session",
|
||||
Cwd: t.TempDir(),
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Run: expected an error, got nil")
|
||||
}
|
||||
|
||||
var streamErr *zeroruntime.StreamError
|
||||
if !errors.As(err, &streamErr) {
|
||||
t.Fatalf("Run error %v (%T) does not unwrap to *zeroruntime.StreamError", err, err)
|
||||
}
|
||||
if streamErr.StatusCode != 500 {
|
||||
t.Fatalf("StatusCode = %d, want 500", streamErr.StatusCode)
|
||||
}
|
||||
if streamErr.Cause != "upstream returned an empty completion" {
|
||||
t.Fatalf("Cause = %q, want %q", streamErr.Cause, "upstream returned an empty completion")
|
||||
}
|
||||
if err.Error() != "provider error: provider returned error" {
|
||||
t.Fatalf("Error() = %q, want the flattened message unchanged", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunImageRejectionKeepsStreamErrorStatusCodeAndCause covers the 400
|
||||
// image-rejection recovery path: the friendly message is kept, but the returned
|
||||
// error must still unwrap to the *StreamError so StatusCode and Cause reach the
|
||||
// session's "error" event (#674).
|
||||
func TestRunImageRejectionKeepsStreamErrorStatusCodeAndCause(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: "provider error 400: image input is not supported",
|
||||
StatusCode: 400,
|
||||
Cause: "this model does not support image input",
|
||||
},
|
||||
}}}
|
||||
|
||||
_, err := Run(context.Background(), "hi", provider, Options{
|
||||
SessionID: "image-rejection-session",
|
||||
Cwd: t.TempDir(),
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Run: expected an error, got nil")
|
||||
}
|
||||
|
||||
want := "model test-model rejected the image: provider error 400: image input is not supported. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)"
|
||||
if err.Error() != want {
|
||||
t.Fatalf("Error() = %q, want the friendly message unchanged: %q", err.Error(), want)
|
||||
}
|
||||
var streamErr *zeroruntime.StreamError
|
||||
if !errors.As(err, &streamErr) {
|
||||
t.Fatalf("Run error %v (%T) does not unwrap to *zeroruntime.StreamError", err, err)
|
||||
}
|
||||
if streamErr.StatusCode != 400 {
|
||||
t.Fatalf("StatusCode = %d, want 400", streamErr.StatusCode)
|
||||
}
|
||||
if streamErr.Cause != "this model does not support image input" {
|
||||
t.Fatalf("Cause = %q, want the upstream cause", streamErr.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSuppressesAdvisoryHooksInPlanMode: plan mode promises a read-only
|
||||
// turn for advisory hooks (sessionStart/sessionEnd/afterTool), which execute
|
||||
// configured host commands outside the advertised-tool and sandbox gates.
|
||||
|
||||
@@ -799,7 +799,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
return exitInterrupted
|
||||
}
|
||||
sessionRecorder.append(sessions.EventError, sessions.ErrorEventPayload(err))
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
|
||||
if options.outputFormat == execOutputStreamJSON {
|
||||
writer.errorEvent("provider_error", err.Error(), false)
|
||||
writer.runEnd("error", exitProvider)
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// streamErrorExecProvider fails every request with one provider error event.
|
||||
type streamErrorExecProvider struct {
|
||||
event zeroruntime.StreamEvent
|
||||
}
|
||||
|
||||
func (provider streamErrorExecProvider) StreamCompletion(ctx context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, 1)
|
||||
ch <- provider.event
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func providerErrorExecDeps(t *testing.T, store *sessions.Store, workspaceRoot string) appDeps {
|
||||
t.Helper()
|
||||
return appDeps{
|
||||
getwd: func() (string, error) { return workspaceRoot, nil },
|
||||
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "test",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "test",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: "test-model",
|
||||
},
|
||||
MaxTurns: 3,
|
||||
}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return streamErrorExecProvider{event: zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: "provider request error: model not found",
|
||||
StatusCode: 404,
|
||||
Cause: "upstream said: no such model",
|
||||
}}, nil
|
||||
},
|
||||
newSessionStore: func() *sessions.Store { return store },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
||||
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// recordedProviderError returns the payload of the one error event the run
|
||||
// recorded, failing unless there is exactly one session with exactly one.
|
||||
func recordedProviderError(t *testing.T, store *sessions.Store) map[string]any {
|
||||
t.Helper()
|
||||
items, err := store.List()
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("sessions = %d, %v; want one", len(items), err)
|
||||
}
|
||||
events, err := store.ReadEvents(items[0].SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
var found []map[string]any
|
||||
for _, event := range events {
|
||||
if event.Type != sessions.EventError {
|
||||
continue
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatalf("unmarshal error payload: %v", err)
|
||||
}
|
||||
found = append(found, payload)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("recorded %d error events, want 1: %#v", len(found), found)
|
||||
}
|
||||
return found[0]
|
||||
}
|
||||
|
||||
func assertProviderErrorDetail(t *testing.T, payload map[string]any) {
|
||||
t.Helper()
|
||||
if got, want := payload["message"], "provider request error: model not found"; got != want {
|
||||
t.Errorf("message = %#v, want %q", got, want)
|
||||
}
|
||||
if got, want := payload["statusCode"], float64(404); got != want {
|
||||
t.Errorf("statusCode = %#v, want %v: the run recorded only the flattened message", got, want)
|
||||
}
|
||||
if got, want := payload["cause"], "upstream said: no such model"; got != want {
|
||||
t.Errorf("cause = %#v, want %q: the run recorded only the flattened message", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A provider error reaches the exec session log with its HTTP status and the
|
||||
// upstream cause, not only the flattened message (#674).
|
||||
func TestRunExecRecordsTheProviderErrorStatusAndCause(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
workspaceRoot := t.TempDir()
|
||||
// exec records through the default store, which isolateCLIUserState has
|
||||
// moved under the test's temp root; this opens the same one.
|
||||
store := sessions.NewStore(sessions.StoreOptions{})
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--cwd", workspaceRoot, "--init-session-id", "provider_error_run", "hello"}, &stdout, &stderr,
|
||||
providerErrorExecDeps(t, store, workspaceRoot))
|
||||
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("exit = %d, want %d; stderr=%s", exitCode, exitProvider, stderr.String())
|
||||
}
|
||||
assertProviderErrorDetail(t, recordedProviderError(t, store))
|
||||
}
|
||||
|
||||
// The spec draft run records its provider error through its own call site, so
|
||||
// it is checked on its own.
|
||||
func TestRunExecUseSpecRecordsTheProviderErrorStatusAndCause(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
workspaceRoot := t.TempDir()
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--cwd", workspaceRoot, "--use-spec", "-o", "json", "plan the review flow"}, &stdout, &stderr,
|
||||
providerErrorExecDeps(t, store, workspaceRoot))
|
||||
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("exit = %d, want %d; stderr=%s stdout=%s", exitCode, exitProvider, stderr.String(), stdout.String())
|
||||
}
|
||||
assertProviderErrorDetail(t, recordedProviderError(t, store))
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
}
|
||||
return exitInterrupted
|
||||
}
|
||||
sessionRecorder.append(sessions.EventError, sessions.ErrorEventPayload(err))
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
|
||||
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, err.Error())
|
||||
}
|
||||
if result.StopReason != agent.StopReasonSpecReviewRequired || draftInfo.SpecID == "" || draftInfo.SpecFilePath == "" {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Twigpine/zero/internal/pathjail"
|
||||
)
|
||||
|
||||
// renameNoReplace publishes a crash report without replacing an existing one.
|
||||
// The implementation lives in pathjail because the file tools need the same
|
||||
// atomic no-replace guarantee.
|
||||
func renameNoReplace(root *os.Root, oldname, newname string) error {
|
||||
return pathjail.RenameNoReplace(root, oldname, newname)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func renameNoReplace(root *os.Root, oldname, newname string) error {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open crash directory for publication: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
return unix.RenameatxNp(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_EXCL)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func renameNoReplace(root *os.Root, oldname, newname string) error {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open crash directory for publication: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
return unix.Renameat2(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_NOREPLACE)
|
||||
}
|
||||
@@ -142,12 +142,22 @@ func trimTrailingSeparators(path string) string {
|
||||
var randomBytes = rand.Read
|
||||
|
||||
// CreateTemp makes an O_EXCL temp file named prefix.<random>suffix inside dir,
|
||||
// relative to handle, and returns it with its relative path.
|
||||
// relative to handle, and returns it with its relative path. The file is
|
||||
// created 0o600 so bytes staged in it are not readable before publication.
|
||||
//
|
||||
// O_EXCL rather than a fixed name: a link planted at a predictable temp path
|
||||
// between the check and the create is the same race this package exists to
|
||||
// close, and O_EXCL makes the kernel refuse it rather than us.
|
||||
func CreateTemp(handle *os.Root, dir, prefix, suffix string) (*os.File, string, error) {
|
||||
return CreateTempMode(handle, dir, prefix, suffix, 0o600)
|
||||
}
|
||||
|
||||
// CreateTempMode is CreateTemp with an explicit create mode. The mode is
|
||||
// passed to the kernel's exclusive create, so the process umask still applies;
|
||||
// callers that must publish the permissions a normal create would have been
|
||||
// granted should read the returned file's mode rather than assigning perm
|
||||
// later, because Chmod does not consult the umask.
|
||||
func CreateTempMode(handle *os.Root, dir, prefix, suffix string, perm os.FileMode) (*os.File, string, error) {
|
||||
// prefix and suffix are NAME fragments, not path fragments. A separator or a
|
||||
// "."/".." in either would relocate the file out of dir — a prefix of
|
||||
// "../escaped", or a suffix of ".tmp/../evil" that also erases the random
|
||||
@@ -164,7 +174,7 @@ func CreateTemp(handle *os.Root, dir, prefix, suffix string) (*os.File, string,
|
||||
return nil, "", fmt.Errorf("generate a temporary name: %w", err)
|
||||
}
|
||||
relative := filepath.Join(dir, prefix+"."+hex.EncodeToString(noise)+suffix)
|
||||
file, err := handle.OpenFile(relative, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
file, err := handle.OpenFile(relative, os.O_RDWR|os.O_CREATE|os.O_EXCL, perm)
|
||||
if err == nil {
|
||||
return file, relative, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package pathjail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// RenameNoReplace renames oldname to newname within the directory bound by
|
||||
// root, and refuses to replace an existing newname with the same atomic
|
||||
// guarantee the kernel gives a create's exclusive open. A caller that loses
|
||||
// the race receives fs.ErrExist and nothing at newname changes.
|
||||
//
|
||||
// Both names must be single path components: they are resolved relative to
|
||||
// root's directory handle, so a separator (or an ancestor link) must not be
|
||||
// able to redirect the syscall outside the checked root. Callers with a path
|
||||
// in a subdirectory open that directory with Root.OpenRoot first.
|
||||
//
|
||||
// Platforms whose kernel cannot publish without replacing fall back to
|
||||
// ErrUnsupported, which lets the caller use a no-replace hard link instead.
|
||||
func RenameNoReplace(root *os.Root, oldname, newname string) error {
|
||||
for _, name := range []string{oldname, newname} {
|
||||
if name == "" || name == "." || name == ".." || filepath.Base(name) != name {
|
||||
return fmt.Errorf("%w: rename name %q must be a single path component", ErrEscapes, name)
|
||||
}
|
||||
}
|
||||
return renameNoReplace(root, oldname, newname)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build darwin
|
||||
|
||||
package pathjail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func renameNoReplace(root *os.Root, oldname, newname string) error {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open directory for rename: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
if err := unix.RenameatxNp(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_EXCL); err != nil {
|
||||
return fmt.Errorf("rename %s to %s: %w", oldname, newname, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build linux
|
||||
|
||||
package pathjail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func renameNoReplace(root *os.Root, oldname, newname string) error {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open directory for rename: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
if err := unix.Renameat2(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_NOREPLACE); err != nil {
|
||||
return fmt.Errorf("rename %s to %s: %w", oldname, newname, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
//go:build !darwin && !linux && !windows
|
||||
|
||||
package observability
|
||||
package pathjail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -0,0 +1,79 @@
|
||||
package pathjail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openRenameRoot(t *testing.T) (*os.Root, string) {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
root, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = root.Close() })
|
||||
return root, base
|
||||
}
|
||||
|
||||
// A CREATE'S NAME MUST NOT BE LOST TO A RACE. The no-replace rename reports the
|
||||
// collision as fs.ErrExist and leaves both files exactly as they were, so the
|
||||
// caller can refuse the create instead of replacing the other writer.
|
||||
func TestRenameNoReplaceRefusesAnExistingDestination(t *testing.T) {
|
||||
root, base := openRenameRoot(t)
|
||||
if err := os.WriteFile(filepath.Join(base, "source"), []byte("source\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, "target"), []byte("target\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := RenameNoReplace(root, "source", "target"); !errors.Is(err, os.ErrExist) {
|
||||
t.Fatalf("RenameNoReplace over an existing name = %v, want os.ErrExist", err)
|
||||
}
|
||||
for name, want := range map[string]string{"source": "source\n", "target": "target\n"} {
|
||||
got, err := os.ReadFile(filepath.Join(base, name))
|
||||
if err != nil || string(got) != want {
|
||||
t.Fatalf("%s = %q, err=%v; a refused rename must not move either file", name, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameNoReplacePublishesAnAbsentDestination(t *testing.T) {
|
||||
root, base := openRenameRoot(t)
|
||||
if err := os.WriteFile(filepath.Join(base, "source"), []byte("whole\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := RenameNoReplace(root, "source", "target"); err != nil {
|
||||
t.Fatalf("RenameNoReplace = %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(base, "target")); err != nil || string(got) != "whole\n" {
|
||||
t.Fatalf("target = %q, err=%v", got, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "source")); !os.IsNotExist(err) {
|
||||
t.Fatalf("source survived a successful rename: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The syscall is given a directory handle and bare names. Allowing a
|
||||
// separator here would let an ancestor component resolve outside the directory
|
||||
// the caller checked, so it is refused before the syscall.
|
||||
func TestRenameNoReplaceRejectsPathComponents(t *testing.T) {
|
||||
root, _ := openRenameRoot(t)
|
||||
for _, tc := range []struct{ oldname, newname string }{
|
||||
{"", "target"},
|
||||
{"source", ""},
|
||||
{".", "target"},
|
||||
{"source", ".."},
|
||||
{"dir/source", "target"},
|
||||
{"source", "dir/target"},
|
||||
{filepath.Join("dir", "source"), "target"},
|
||||
} {
|
||||
if err := RenameNoReplace(root, tc.oldname, tc.newname); !errors.Is(err, ErrEscapes) {
|
||||
t.Errorf("RenameNoReplace(%q, %q) = %v, want ErrEscapes", tc.oldname, tc.newname, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
//go:build windows
|
||||
|
||||
package observability
|
||||
package pathjail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -212,15 +212,34 @@ type errorPayload struct {
|
||||
// response.output_item.added, the argument deltas, and the final
|
||||
// response.output_item.done.
|
||||
type toolCallBuilder struct {
|
||||
ID string
|
||||
CallID string
|
||||
Name string
|
||||
ID string
|
||||
CallID string
|
||||
Name string
|
||||
// Arguments holds exactly the input already forwarded to the runtime as
|
||||
// tool-call deltas: every write goes through forwardInput, so an empty
|
||||
// builder means the runtime has received nothing for this call yet. The
|
||||
// done handler relies on that to forward input that only arrived whole
|
||||
// (on the added or done item) without duplicating streamed fragments.
|
||||
Arguments strings.Builder
|
||||
Freeform bool
|
||||
started bool
|
||||
ended bool
|
||||
}
|
||||
|
||||
// forwardInput appends fragment to the builder and emits the matching
|
||||
// tool-call delta, keeping the builder and the runtime in step.
|
||||
func (builder *toolCallBuilder) forwardInput(ctx context.Context, events chan<- zeroruntime.StreamEvent, fragment string) {
|
||||
if fragment == "" {
|
||||
return
|
||||
}
|
||||
builder.Arguments.WriteString(fragment)
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallDelta,
|
||||
ToolCallID: builder.ID,
|
||||
ArgumentsFragment: fragment,
|
||||
})
|
||||
}
|
||||
|
||||
// responsesState tracks in-flight tool calls and whether the terminal
|
||||
// event has been emitted. It is the Responses-API equivalent of the
|
||||
// chat-completions toolState in provider.go.
|
||||
@@ -726,9 +745,6 @@ func (p *CodexProvider) handleOutputItemAdded(
|
||||
builder.Name = event.Item.Name
|
||||
}
|
||||
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
|
||||
if builder.Freeform && event.Item.Input != "" && builder.Arguments.Len() == 0 {
|
||||
builder.Arguments.WriteString(event.Item.Input)
|
||||
}
|
||||
state.emitted = true
|
||||
if !builder.started {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
@@ -740,6 +756,11 @@ func (p *CodexProvider) handleOutputItemAdded(
|
||||
})
|
||||
builder.started = true
|
||||
}
|
||||
// A custom tool call may arrive with its whole input on the added item
|
||||
// instead of input deltas; the runtime needs it as a delta either way.
|
||||
if builder.Freeform && builder.Arguments.Len() == 0 {
|
||||
builder.forwardInput(ctx, events, event.Item.Input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,13 +794,8 @@ func (p *CodexProvider) handleToolInputDelta(
|
||||
if event.CallID != "" && builder.CallID == "" {
|
||||
builder.CallID = event.CallID
|
||||
}
|
||||
builder.Arguments.WriteString(event.Delta)
|
||||
state.emitted = true
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallDelta,
|
||||
ToolCallID: key,
|
||||
ArgumentsFragment: event.Delta,
|
||||
})
|
||||
builder.forwardInput(ctx, events, event.Delta)
|
||||
}
|
||||
|
||||
// handleOutputItemDone finalizes a tool call when the item type is
|
||||
@@ -813,13 +829,6 @@ func (p *CodexProvider) handleOutputItemDone(
|
||||
builder.CallID = event.Item.CallID
|
||||
}
|
||||
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
|
||||
finalInput := event.Item.Arguments
|
||||
if builder.Freeform {
|
||||
finalInput = event.Item.Input
|
||||
}
|
||||
if finalInput != "" && builder.Arguments.Len() == 0 {
|
||||
builder.Arguments.WriteString(finalInput)
|
||||
}
|
||||
if !builder.started {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallStart,
|
||||
@@ -830,6 +839,16 @@ func (p *CodexProvider) handleOutputItemDone(
|
||||
})
|
||||
builder.started = true
|
||||
}
|
||||
// The done item repeats the complete input. When nothing reached the
|
||||
// runtime through deltas (or the added item), this is the only copy, so
|
||||
// forward it before closing the call; otherwise the streamed copy stands.
|
||||
if builder.Arguments.Len() == 0 {
|
||||
finalInput := event.Item.Arguments
|
||||
if builder.Freeform {
|
||||
finalInput = event.Item.Input
|
||||
}
|
||||
builder.forwardInput(ctx, events, finalInput)
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallEnd,
|
||||
ToolCallID: key,
|
||||
|
||||
@@ -903,6 +903,145 @@ func TestCodexProviderParsesResponsesToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime only sees tool input through tool-call deltas. The Codex
|
||||
// backend may deliver that input incrementally, whole on the done item, or
|
||||
// (for custom tool calls) whole on the added item; whichever way it arrives,
|
||||
// the deltas before tool-call-end must carry it exactly once, and the replayed
|
||||
// history item must hold the same input.
|
||||
func TestCodexOutputItemDoneForwardsUnstreamedArguments(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
functionArguments = `{"path":"README.md"}`
|
||||
customInput = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
|
||||
)
|
||||
cases := []struct {
|
||||
name string
|
||||
freeform bool
|
||||
added bool // response.output_item.added precedes the input
|
||||
addedInput bool // the added item already carries the whole input
|
||||
streamed bool // input arrives through *_delta events
|
||||
}{
|
||||
{name: "function/added/streamed", added: true, streamed: true},
|
||||
{name: "function/added/final-only", added: true},
|
||||
{name: "function/no-added/streamed", streamed: true},
|
||||
{name: "function/no-added/final-only"},
|
||||
{name: "custom/added/streamed", freeform: true, added: true, streamed: true},
|
||||
{name: "custom/added/final-only", freeform: true, added: true},
|
||||
{name: "custom/no-added/streamed", freeform: true, streamed: true},
|
||||
{name: "custom/no-added/final-only", freeform: true},
|
||||
{name: "custom/input-on-added", freeform: true, added: true, addedInput: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
state := newResponsesState()
|
||||
events := make(chan zeroruntime.StreamEvent, 10)
|
||||
provider := &CodexProvider{}
|
||||
item := &itemPayload{Type: "function_call", ID: "item-1", CallID: "call-1", Name: "read_file", Arguments: functionArguments}
|
||||
want := functionArguments
|
||||
if tc.freeform {
|
||||
item.Type = "custom_tool_call"
|
||||
item.Name = "apply_patch"
|
||||
item.Arguments = ""
|
||||
item.Input = customInput
|
||||
want = customInput
|
||||
}
|
||||
ctx := context.Background()
|
||||
if tc.added {
|
||||
initial := *item
|
||||
if !tc.addedInput {
|
||||
initial.Arguments = ""
|
||||
initial.Input = ""
|
||||
}
|
||||
provider.handleOutputItemAdded(ctx, &responsesEvent{Item: &initial}, state, events)
|
||||
}
|
||||
if tc.streamed {
|
||||
provider.handleToolInputDelta(ctx, &responsesEvent{ItemID: item.ID, Delta: want}, state, events, tc.freeform)
|
||||
}
|
||||
provider.handleOutputItemDone(ctx, &responsesEvent{Item: item}, state, events)
|
||||
close(events)
|
||||
var arguments strings.Builder
|
||||
var starts, ends int
|
||||
for event := range events {
|
||||
switch event.Type {
|
||||
case zeroruntime.StreamEventToolCallStart:
|
||||
starts++
|
||||
case zeroruntime.StreamEventToolCallDelta:
|
||||
arguments.WriteString(event.ArgumentsFragment)
|
||||
case zeroruntime.StreamEventToolCallEnd:
|
||||
ends++
|
||||
if arguments.String() != want {
|
||||
t.Errorf("arguments at tool-call-end = %q, want %q", arguments.String(), want)
|
||||
}
|
||||
}
|
||||
if event.ToolCallID != item.ID {
|
||||
t.Errorf("tool call ID = %q, want %q", event.ToolCallID, item.ID)
|
||||
}
|
||||
}
|
||||
if starts != 1 || ends != 1 {
|
||||
t.Errorf("starts=%d ends=%d, want 1 each", starts, ends)
|
||||
}
|
||||
got := state.outputInputItems()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("output items = %v, want one tool call", got)
|
||||
}
|
||||
replayed := got[0].Arguments
|
||||
if tc.freeform {
|
||||
replayed = got[0].Input
|
||||
}
|
||||
if replayed != want {
|
||||
t.Errorf("replayed history input = %q, want %q", replayed, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The same cases through the runtime's collector, which is what the agent
|
||||
// loop consumes: a function call whose arguments exist only on the done item
|
||||
// and a custom tool call whose input exists only on the added item both reach
|
||||
// the runtime with their input intact.
|
||||
func TestCodexStreamCollectsToolInputDeliveredWhole(t *testing.T) {
|
||||
t.Parallel()
|
||||
var rec codexRequest
|
||||
srv := newCodexResponsesServer(t, &rec,
|
||||
`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"read_file"}}`,
|
||||
`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"read_file","arguments":"{\"path\":\"README.md\"}"}}`,
|
||||
`{"type":"response.output_item.added","output_index":1,"item":{"type":"custom_tool_call","id":"ct-1","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch\n*** End Patch\n"}}`,
|
||||
`{"type":"response.output_item.done","output_index":1,"item":{"type":"custom_tool_call","id":"ct-1","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch\n*** End Patch\n"}}`,
|
||||
`{"type":"response.completed","response":{"id":"resp-1","status":"completed","usage":{"input_tokens":3,"output_tokens":2}}}`,
|
||||
)
|
||||
defer srv.Close()
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "read the readme"}},
|
||||
Tools: []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"type": "object"}},
|
||||
{Name: "apply_patch", Type: zeroruntime.ToolDefinitionFreeform},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
collected := zeroruntime.CollectStream(context.Background(), stream)
|
||||
if collected.Error != "" {
|
||||
t.Fatalf("unexpected stream error: %q", collected.Error)
|
||||
}
|
||||
if len(collected.ToolCalls) != 2 {
|
||||
t.Fatalf("tool calls = %+v, want read_file and apply_patch", collected.ToolCalls)
|
||||
}
|
||||
if call := collected.ToolCalls[0]; call.Name != "read_file" || call.Arguments != `{"path":"README.md"}` || call.Freeform {
|
||||
t.Errorf("function call = %+v, want read_file with the done item's arguments", call)
|
||||
}
|
||||
if call := collected.ToolCalls[1]; call.Name != "apply_patch" || call.Arguments != "*** Begin Patch\n*** End Patch\n" || !call.Freeform {
|
||||
t.Errorf("custom call = %+v, want apply_patch with the added item's input", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderSendsAssistantToolCallsAsInputItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
// When the runtime replays a prior assistant turn that issued a tool
|
||||
|
||||
@@ -298,32 +298,26 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *t
|
||||
if chunk.Error != nil {
|
||||
state.flushContent(ctx, events)
|
||||
state.closeOpen(ctx, events)
|
||||
// statusCode buckets the error for classification only. The HTTP response
|
||||
// was 200, so observedStatus stays 0 unless the payload's code maps to a
|
||||
// known status; a bucket default must not be persisted as a real status.
|
||||
statusCode := http.StatusInternalServerError
|
||||
observedStatus := 0
|
||||
if chunk.Error.Code != nil {
|
||||
switch c := chunk.Error.Code.(type) {
|
||||
case string:
|
||||
if code, ok := openAIStreamErrorStatusByCode[c]; ok {
|
||||
statusCode, observedStatus = code, code
|
||||
statusCode = code
|
||||
}
|
||||
case float64:
|
||||
if code, ok := openAIStreamErrorStatusByCode[strconv.Itoa(int(c))]; ok {
|
||||
statusCode, observedStatus = code, code
|
||||
statusCode = code
|
||||
}
|
||||
case int:
|
||||
if code, ok := openAIStreamErrorStatusByCode[strconv.Itoa(c)]; ok {
|
||||
statusCode, observedStatus = code, code
|
||||
statusCode = code
|
||||
}
|
||||
}
|
||||
}
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(statusCode, chunk.Error.Message),
|
||||
StatusCode: observedStatus,
|
||||
Cause: provider.redact(chunk.Error.Message),
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(statusCode, chunk.Error.Message),
|
||||
})
|
||||
state.done = true
|
||||
return false
|
||||
@@ -410,19 +404,12 @@ func (provider *Provider) emitHTTPError(ctx context.Context, response *http.Resp
|
||||
// localhost but returns a gateway error when it cannot reach its own backend.
|
||||
// Surface that as a clear connectivity message instead of the raw proxied body.
|
||||
if humanized, ok := providerio.UpstreamUnreachable(message); ok {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.redact(humanized),
|
||||
StatusCode: response.StatusCode,
|
||||
Cause: provider.redact(message),
|
||||
})
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact(humanized)})
|
||||
return
|
||||
}
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(response.StatusCode, message),
|
||||
StatusCode: response.StatusCode,
|
||||
Cause: provider.redact(message),
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(response.StatusCode, message),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -500,35 +500,6 @@ func TestStreamCompletionClassifiesHTTPErrorsAndRedactsToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamCompletionHTTPErrorCarriesStatusCodeAndCause verifies the StreamEvent
|
||||
// for an HTTP-level provider failure carries the raw status code and a redacted
|
||||
// cause separately from the classified, user-facing Error string — the detail
|
||||
// a session's "error" event needs to actually diagnose why a provider call
|
||||
// failed instead of only storing the flattened message (#674).
|
||||
func TestStreamCompletionHTTPErrorCarriesStatusCodeAndCause(t *testing.T) {
|
||||
provider := newTestProviderWithKey(t, "sk-secret", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":{"message":"upstream saw Bearer sk-secret"}}`, http.StatusInternalServerError)
|
||||
})
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
events := readAll(stream)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
event := events[0]
|
||||
if event.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("StatusCode = %d, want %d", event.StatusCode, http.StatusInternalServerError)
|
||||
}
|
||||
if !strings.Contains(event.Cause, "upstream saw Bearer") {
|
||||
t.Fatalf("Cause = %q, want it to contain the upstream detail", event.Cause)
|
||||
}
|
||||
if strings.Contains(event.Cause, "sk-secret") {
|
||||
t.Fatalf("Cause leaked token: %q", event.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionHumanizesUpstreamUnreachableGatewayError(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A local Ollama daemon serving a "-cloud" model answers on localhost but
|
||||
@@ -555,15 +526,6 @@ func TestStreamCompletionHumanizesUpstreamUnreachableGatewayError(t *testing.T)
|
||||
t.Fatalf("error = %q, want it to contain %q", got, want)
|
||||
}
|
||||
}
|
||||
// The humanized "upstream unreachable" message must not drop the response
|
||||
// metadata a session error event needs — StatusCode/Cause must still reflect
|
||||
// the real HTTP response, matching the classified-error path below it (#674).
|
||||
if events[0].StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("StatusCode = %d, want %d", events[0].StatusCode, http.StatusBadGateway)
|
||||
}
|
||||
if !strings.Contains(events[0].Cause, "TLS handshake timeout") {
|
||||
t.Fatalf("Cause = %q, want it to contain the raw upstream detail", events[0].Cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
||||
@@ -622,50 +584,6 @@ func TestStreamCompletionClassifiesStreamErrorCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamCompletionStreamedErrorCarriesStatusCodeAndCause mirrors
|
||||
// TestStreamCompletionHTTPErrorCarriesStatusCodeAndCause for the other error
|
||||
// path: an error arriving inside a 200 OK SSE payload's "code" field rather
|
||||
// than the HTTP status (#674).
|
||||
func TestStreamCompletionStreamedErrorCarriesStatusCodeAndCause(t *testing.T) {
|
||||
provider := newTestProviderWithKey(t, "sk-secret", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"error":{"message":"rate limited sk-secret, see Bearer token docs","code":"429"}}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
event := events[0]
|
||||
if event.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("StatusCode = %d, want %d", event.StatusCode, http.StatusTooManyRequests)
|
||||
}
|
||||
if !strings.Contains(event.Cause, "rate limited") {
|
||||
t.Fatalf("Cause = %q, want it to contain the upstream detail", event.Cause)
|
||||
}
|
||||
if strings.Contains(event.Cause, "sk-secret") {
|
||||
t.Fatalf("Cause leaked token: %q", event.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// An error inside a 200 OK SSE payload whose code is not a known status was
|
||||
// never an observed HTTP status, so none must be reported (#674 review).
|
||||
func TestStreamCompletionStreamedErrorWithUnknownCodeReportsNoStatus(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"error":{"message":"model overloaded","code":"server_error"}}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
if events[0].StatusCode != 0 {
|
||||
t.Fatalf("StatusCode = %d, want 0 (no HTTP status was observed)", events[0].StatusCode)
|
||||
}
|
||||
if !strings.Contains(events[0].Cause, "model overloaded") {
|
||||
t.Fatalf("Cause = %q, want the upstream detail", events[0].Cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsErrorForMalformedJSON(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":`)
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// maxErrorCauseBytes bounds the persisted cause. Provider error bodies are read
|
||||
// up to 64 KiB, which is far more than a diagnostic needs and would otherwise be
|
||||
// copied whole into every error event in events.jsonl.
|
||||
const maxErrorCauseBytes = 8 << 10
|
||||
|
||||
const errorCauseTruncationMarker = "… [truncated]"
|
||||
|
||||
// ErrorEventPayload builds the payload for an EventError session event. It
|
||||
// always includes the flattened message (unchanged, for backward
|
||||
// compatibility with existing consumers of events.jsonl), and additionally
|
||||
// persists the HTTP status code and upstream cause when err is (or wraps) a
|
||||
// *zeroruntime.StreamError — the structured error a failed provider stream
|
||||
// returns. Without this, a provider error left no way to diagnose *why* a
|
||||
// call failed from the CLI or its logs — only the generic top-level message
|
||||
// was ever recorded (#674). Cause has already been redacted for secrets by
|
||||
// the provider before reaching here (see zeroruntime.StreamEvent.Cause) — it
|
||||
// is never re-scrubbed or stored raw at this layer. Invalid UTF-8 in the cause
|
||||
// is replaced with U+FFFD, and a longer cause is cut on a rune boundary and
|
||||
// marked; the marked result, marker included, stays within maxErrorCauseBytes
|
||||
// as it reads back from the store.
|
||||
func ErrorEventPayload(err error) map[string]any {
|
||||
payload := map[string]any{"message": err.Error()}
|
||||
var streamErr *zeroruntime.StreamError
|
||||
if errors.As(err, &streamErr) {
|
||||
if streamErr.StatusCode != 0 {
|
||||
payload["statusCode"] = streamErr.StatusCode
|
||||
}
|
||||
if streamErr.Cause != "" {
|
||||
payload["cause"] = boundErrorCause(streamErr.Cause)
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func boundErrorCause(cause string) string {
|
||||
// Made valid first, so the bound applies to what the store keeps. The
|
||||
// payload is written as JSON, which turns every invalid byte into a
|
||||
// three-byte U+FFFD, so a body of invalid bytes cut to the limit here came
|
||||
// back from events.jsonl at up to three times it.
|
||||
cause = strings.ToValidUTF8(cause, "\uFFFD")
|
||||
if len(cause) <= maxErrorCauseBytes {
|
||||
return cause
|
||||
}
|
||||
// The marker counts toward the bound, so the cut leaves room for it.
|
||||
return truncateUTF8(cause, maxErrorCauseBytes-len(errorCauseTruncationMarker)) + errorCauseTruncationMarker
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestErrorEventPayloadPlainError(t *testing.T) {
|
||||
payload := ErrorEventPayload(errors.New("provider error: provider returned error"))
|
||||
|
||||
if got, want := payload["message"], "provider error: provider returned error"; got != want {
|
||||
t.Fatalf("message = %v, want %v", got, want)
|
||||
}
|
||||
if _, ok := payload["statusCode"]; ok {
|
||||
t.Fatalf("statusCode should be absent for a plain error, got %v", payload["statusCode"])
|
||||
}
|
||||
if _, ok := payload["cause"]; ok {
|
||||
t.Fatalf("cause should be absent for a plain error, got %v", payload["cause"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorEventPayloadStreamError(t *testing.T) {
|
||||
err := &zeroruntime.StreamError{
|
||||
Message: "provider error: provider returned error",
|
||||
StatusCode: 500,
|
||||
Cause: "upstream returned an empty completion",
|
||||
}
|
||||
|
||||
payload := ErrorEventPayload(err)
|
||||
|
||||
if got, want := payload["message"], "provider error: provider returned error"; got != want {
|
||||
t.Fatalf("message = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := payload["statusCode"], 500; got != want {
|
||||
t.Fatalf("statusCode = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := payload["cause"], "upstream returned an empty completion"; got != want {
|
||||
t.Fatalf("cause = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorEventPayloadWrappedStreamError(t *testing.T) {
|
||||
// A StreamError wrapped by another layer (e.g. fmt.Errorf("...: %w", err))
|
||||
// must still surface its status/cause via errors.As, not just a direct type
|
||||
// assertion — this is the shape #674's real repro chain produces.
|
||||
inner := &zeroruntime.StreamError{Message: "rate limit error: too many requests", StatusCode: 429, Cause: "retry after 30s"}
|
||||
wrapped := fmt.Errorf("turn failed: %w", inner)
|
||||
|
||||
payload := ErrorEventPayload(wrapped)
|
||||
|
||||
if got, want := payload["statusCode"], 429; got != want {
|
||||
t.Fatalf("statusCode = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := payload["cause"], "retry after 30s"; got != want {
|
||||
t.Fatalf("cause = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := payload["message"], "turn failed: rate limit error: too many requests"; got != want {
|
||||
t.Fatalf("message = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorEventPayloadZeroStatusCodeOmitted(t *testing.T) {
|
||||
// A transport-level failure (e.g. context cancellation) never got an HTTP
|
||||
// response, so StatusCode is legitimately 0 — that must stay omitted rather
|
||||
// than be persisted as a misleading "statusCode": 0.
|
||||
err := &zeroruntime.StreamError{Message: "context deadline exceeded"}
|
||||
|
||||
payload := ErrorEventPayload(err)
|
||||
|
||||
if _, ok := payload["statusCode"]; ok {
|
||||
t.Fatalf("statusCode should be omitted when zero, got %v", payload["statusCode"])
|
||||
}
|
||||
if _, ok := payload["cause"]; ok {
|
||||
t.Fatalf("cause should be omitted when empty, got %v", payload["cause"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventPersistsErrorEventStatusCodeAndCause(t *testing.T) {
|
||||
// Closes the seam between ErrorEventPayload (unit tested above) and Run
|
||||
// returning a *StreamError (tested in internal/agent): this asserts the
|
||||
// two actually meet at the Store, i.e. a *StreamError survives a real
|
||||
// AppendEvent/ReadEvents round trip with statusCode/cause intact.
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-08-10T12:00:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "error_join"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
streamErr := &zeroruntime.StreamError{
|
||||
Message: "provider error: provider returned error",
|
||||
StatusCode: 503,
|
||||
Cause: "upstream returned an empty completion",
|
||||
}
|
||||
|
||||
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{
|
||||
Type: EventError,
|
||||
Payload: ErrorEventPayload(streamErr),
|
||||
}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Type != EventError {
|
||||
t.Fatalf("expected a single error event, got %#v", events)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(events[0].Payload, &payload); err != nil {
|
||||
t.Fatalf("failed to unmarshal persisted payload: %v", err)
|
||||
}
|
||||
if got, want := payload["message"], "provider error: provider returned error"; got != want {
|
||||
t.Fatalf("persisted message = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := payload["statusCode"], float64(503); got != want {
|
||||
t.Fatalf("persisted statusCode = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := payload["cause"], "upstream returned an empty completion"; got != want {
|
||||
t.Fatalf("persisted cause = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorEventPayloadBoundsCause(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cause string
|
||||
}{
|
||||
// "é" is two bytes; an odd cap boundary must not split it.
|
||||
{"two-byte runes", strings.Repeat("é", maxErrorCauseBytes)},
|
||||
{"one byte over the cap", strings.Repeat("a", maxErrorCauseBytes+1)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := ErrorEventPayload(&zeroruntime.StreamError{Message: "m", StatusCode: 500, Cause: tc.cause})
|
||||
|
||||
got, ok := payload["cause"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("cause = %#v, want string", payload["cause"])
|
||||
}
|
||||
if !strings.HasSuffix(got, errorCauseTruncationMarker) {
|
||||
t.Fatalf("cause missing truncation marker: ...%q", got[len(got)-20:])
|
||||
}
|
||||
// The marker counts: the persisted cause as a whole stays within the cap.
|
||||
if len(got) > maxErrorCauseBytes {
|
||||
t.Fatalf("bounded cause is %d bytes with its marker, want <= %d", len(got), maxErrorCauseBytes)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatal("bounded cause is not valid UTF-8")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorEventPayloadKeepsCauseAtCap(t *testing.T) {
|
||||
cause := strings.Repeat("a", maxErrorCauseBytes)
|
||||
payload := ErrorEventPayload(&zeroruntime.StreamError{Message: "m", Cause: cause})
|
||||
|
||||
if got := payload["cause"]; got != cause {
|
||||
t.Fatalf("cause at exactly the cap was altered (len %d)", len(got.(string)))
|
||||
}
|
||||
}
|
||||
|
||||
// The bound holds for the cause as it reads back from events.jsonl, not only
|
||||
// for the string handed to AppendEvent. JSON turns each invalid byte into a
|
||||
// three-byte U+FFFD, so an error body of invalid bytes cut to the limit came
|
||||
// back at up to three times it.
|
||||
func TestAnInvalidUTF8CauseStaysWithinTheBoundAfterTheStoreRoundTrip(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cause string
|
||||
}{
|
||||
{"invalid bytes between ASCII", strings.Repeat("\xffa", maxErrorCauseBytes)},
|
||||
{"a run of invalid bytes", strings.Repeat("\xfe", maxErrorCauseBytes) + "tail"},
|
||||
{"short and invalid", "status \xff\xfe body"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-11-09T12:00:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "invalid_cause"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
streamErr := &zeroruntime.StreamError{Message: "provider error", StatusCode: 502, Cause: tc.cause}
|
||||
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventError, Payload: ErrorEventPayload(streamErr)}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("ReadEvents = %d events, %v", len(events), err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(events[0].Payload, &payload); err != nil {
|
||||
t.Fatalf("unmarshal persisted payload: %v", err)
|
||||
}
|
||||
got, ok := payload["cause"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("persisted cause = %#v, want a string", payload["cause"])
|
||||
}
|
||||
if len(got) > maxErrorCauseBytes {
|
||||
t.Fatalf("persisted cause reads back at %d bytes, want <= %d", len(got), maxErrorCauseBytes)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatal("persisted cause is not valid UTF-8")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+332
-28
@@ -5,20 +5,63 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/Twigpine/zero/internal/pathjail"
|
||||
)
|
||||
|
||||
var errFileChangedDuringWrite = errors.New("file changed on disk before the write committed")
|
||||
|
||||
// errFilePublishedWithCleanupFailure marks an error after publication. Callers
|
||||
// must record the committed bytes while still reporting the cleanup failure.
|
||||
var errFilePublishedWithCleanupFailure = errors.New("file published, but staging cleanup failed")
|
||||
|
||||
// fileWriteBeforeCommit is a deterministic test hook. Production leaves it
|
||||
// nil; tests use it to replace a path after observation but before opening the
|
||||
// object that will actually be mutated.
|
||||
var fileWriteBeforeCommit func(path string)
|
||||
|
||||
// fileWriteBeforePublish is a deterministic test hook that runs after the
|
||||
// replacement bytes have been staged and before the destination's identity is
|
||||
// re-checked for the publish. Tests use it to install a competing writer at the
|
||||
// last point before publication.
|
||||
var fileWriteBeforePublish func(path string)
|
||||
|
||||
// fileWriteStat is a deterministic test seam for proving that the opened-file
|
||||
// identity is captured before the final preimage comparison. Production uses
|
||||
// the file descriptor directly.
|
||||
var fileWriteStat = func(file *os.File) (os.FileInfo, error) { return file.Stat() }
|
||||
|
||||
// fileWriteTempWrite is a deterministic test seam for a failure while the
|
||||
// replacement bytes are written to the side file, after the observed file's
|
||||
// identity and preimage have been accepted. Production writes through
|
||||
// io.WriteString. The failure it stands in for (a full disk, a failing
|
||||
// device) is exactly the moment a truncate-then-write has already destroyed
|
||||
// the observed file.
|
||||
var fileWriteTempWrite = func(file *os.File, content string) error {
|
||||
_, err := io.WriteString(file, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// fileWriteRename publishes the staged side file over the destination.
|
||||
// Tests replace it to exercise refused atomic publication deterministically.
|
||||
var fileWriteRename = func(root *os.Root, oldName, newName string) error {
|
||||
return root.Rename(oldName, newName)
|
||||
}
|
||||
|
||||
// fileWriteNoReplaceRename publishes a create without replacing an existing
|
||||
// name. Tests replace it to exercise the hard-link fallback that filesystems
|
||||
// without a no-replace rename use.
|
||||
var fileWriteNoReplaceRename = publishRootedNoReplace
|
||||
|
||||
// fileWriteRemove removes a staging name. Tests inject cleanup failures after
|
||||
// publication to distinguish a committed create from a failed write.
|
||||
var fileWriteRemove = func(root *os.Root, name string) error {
|
||||
return root.Remove(name)
|
||||
}
|
||||
|
||||
// commitRootedFileContents binds an overwrite to the file identity and bytes
|
||||
// that the caller observed, opening every component through an already-open
|
||||
// *os.Root descriptor rather than re-resolving the absolute path. Because the
|
||||
@@ -26,11 +69,26 @@ var fileWriteStat = func(file *os.File) (os.FileInfo, error) { return file.Stat(
|
||||
// swapped for a symlink that escapes the workspace between validation and this
|
||||
// call is refused by the kernel instead of redirecting the write.
|
||||
//
|
||||
// A create uses exclusive creation THROUGH THE ROOT. An overwrite opens the
|
||||
// observed object without truncation, verifies identity/content through that
|
||||
// handle, then truncates and writes the same handle. relativePath is the target
|
||||
// expressed relative to root; absolutePath is retained only for the test hook
|
||||
// and error reporting.
|
||||
// Both publishes are atomic against a concurrent reader: the replacement bytes
|
||||
// are written whole to a side file in the target's directory and the target is
|
||||
// swapped wholesale. A create publishes through a kernel no-replace rename, so
|
||||
// losing the create race refuses instead of replacing the raced file. An
|
||||
// overwrite publishes through a rename after re-checking that the destination
|
||||
// still names the observed object; that check is the last operation before the
|
||||
// rename, but no supported platform offers a compare-and-swap rename, so a
|
||||
// writer that replaces the name between the check and the syscall can still be
|
||||
// replaced. A failure at any point before the publish removes the side file
|
||||
// and leaves the observed bytes standing — the caller's file outlives a write
|
||||
// that could not complete, and no partial state is ever visible at the path
|
||||
// itself.
|
||||
//
|
||||
// A final-component symlink keeps main's behaviour: the link stays a link and
|
||||
// the resolved file is what is replaced. A hard link is detached by the rename
|
||||
// instead of rewritten through the shared inode. If the platform refuses the
|
||||
// rename, the overwrite fails and the observed file remains intact.
|
||||
//
|
||||
// relativePath is the target expressed relative to root; absolutePath is
|
||||
// retained only for the test hooks and error reporting.
|
||||
//
|
||||
// expectedInfo nil means the caller observed a missing path. expectedContent
|
||||
// may be nil for an existing but unreadable file; that path may still be
|
||||
@@ -41,16 +99,7 @@ func commitRootedFileContents(root *os.Root, absolutePath, relativePath string,
|
||||
}
|
||||
|
||||
if expectedInfo == nil {
|
||||
file, err := root.OpenFile(relativePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
openedInfo, err := fileWriteStat(file)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
return writeAndVerifyRootedFileIdentity(root, relativePath, file, openedInfo, content, false)
|
||||
return commitRootedFileCreate(root, relativePath, content)
|
||||
}
|
||||
|
||||
flags := os.O_WRONLY
|
||||
@@ -86,24 +135,239 @@ func commitRootedFileContents(root *os.Root, absolutePath, relativePath string,
|
||||
return errFileChangedDuringWrite
|
||||
}
|
||||
}
|
||||
return writeAndVerifyRootedFileIdentity(root, relativePath, file, openedInfo, content, true)
|
||||
}
|
||||
|
||||
func writeAndVerifyRootedFileIdentity(root *os.Root, relativePath string, file *os.File, openedInfo os.FileInfo, content string, truncate bool) error {
|
||||
if truncate {
|
||||
if err := file.Truncate(0); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
// A device, FIFO or socket is not a file this path should replace with a
|
||||
// regular one, and the previous in-place write is the only publish that
|
||||
// keeps its semantics. Regular files (including symlink targets) go through
|
||||
// staging and rename.
|
||||
if !openedInfo.Mode().IsRegular() {
|
||||
return writeRootedFileInPlace(root, relativePath, file, openedInfo, expectedContent, content)
|
||||
}
|
||||
if _, err := io.WriteString(file, content); err != nil {
|
||||
mode := openedInfo.Mode().Perm()
|
||||
publishPath, err := resolveRootedFinalPath(root, relativePath)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if publishPath != relativePath {
|
||||
targetInfo, statErr := root.Stat(publishPath)
|
||||
if statErr != nil || !os.SameFile(openedInfo, targetInfo) {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("%w: link target changed", errFileChangedDuringWrite)
|
||||
}
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return commitRootedFileOverwrite(root, absolutePath, relativePath, publishPath, openedInfo, mode, content)
|
||||
}
|
||||
|
||||
// commitRootedFileCreate publishes a brand-new file whose absence the caller
|
||||
// observed. The bytes are written whole to a private side file beside the
|
||||
// target and handed to the name with a kernel no-replace rename: the create is
|
||||
// either absent or the whole file, a concurrent create is refused rather than
|
||||
// replaced, and a write that cannot complete leaves no file at all.
|
||||
func commitRootedFileCreate(root *os.Root, relativePath, content string) error {
|
||||
tempName, err := writeRootedSideFile(root, relativePath, 0o644, true, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := publishRootedCreate(root, tempName, relativePath); err != nil {
|
||||
if errors.Is(err, errFilePublishedWithCleanupFailure) {
|
||||
return err
|
||||
}
|
||||
return errors.Join(err, removeWriteSideFile(root, tempName))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishRootedCreate hands the staged side file to a name that must not
|
||||
// already exist. RenameNoReplace is the primary primitive; a hard link is the
|
||||
// no-replace fallback for filesystems whose rename cannot promise it. Neither
|
||||
// fallback copies into a visible target, so a reader sees only the absent name
|
||||
// or the complete file.
|
||||
func publishRootedCreate(root *os.Root, tempName, relativePath string) error {
|
||||
renameErr := fileWriteNoReplaceRename(root, tempName, relativePath)
|
||||
if renameErr == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(renameErr, os.ErrExist) {
|
||||
return fmt.Errorf("creating %s: %w", relativePath, renameErr)
|
||||
}
|
||||
if !renameNoReplaceUnsupported(renameErr) {
|
||||
return fmt.Errorf("creating %s: %w", relativePath, renameErr)
|
||||
}
|
||||
if linkErr := root.Link(tempName, relativePath); linkErr != nil {
|
||||
return fmt.Errorf("creating %s: %w", relativePath, errors.Join(renameErr, linkErr))
|
||||
}
|
||||
// The staging name now shares the published inode. Remove only the name,
|
||||
// without chmod-based retries that could change the published file's mode.
|
||||
if cleanupErr := fileWriteRemove(root, tempName); cleanupErr != nil && !errors.Is(cleanupErr, os.ErrNotExist) {
|
||||
return fmt.Errorf("creating %s: %w: %w", relativePath, errFilePublishedWithCleanupFailure, cleanupErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishRootedNoReplace performs the no-replace rename through a root handle
|
||||
// bound to the target's own directory. Binding the directory first is what
|
||||
// keeps the raw rename inside the workspace: a parent link swapped after the
|
||||
// caller resolved the path is refused when the directory is opened, and both
|
||||
// names are single components below that handle.
|
||||
func publishRootedNoReplace(root *os.Root, tempName, relativePath string) error {
|
||||
parent := filepath.Dir(relativePath)
|
||||
if filepath.Dir(tempName) != parent {
|
||||
return fmt.Errorf("staging file %s is not beside %s", tempName, relativePath)
|
||||
}
|
||||
directory, err := root.OpenRoot(parent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer directory.Close()
|
||||
return pathjail.RenameNoReplace(directory, filepath.Base(tempName), filepath.Base(relativePath))
|
||||
}
|
||||
|
||||
// renameNoReplaceUnsupported reports whether the platform or filesystem told
|
||||
// us only that it cannot perform a no-replace rename, so the no-replace hard
|
||||
// link is the right fallback. A collision is not an unsupported operation and
|
||||
// is handled by the caller before this is consulted.
|
||||
func renameNoReplaceUnsupported(err error) bool {
|
||||
return errors.Is(err, errors.ErrUnsupported) || errors.Is(err, syscall.EINVAL)
|
||||
}
|
||||
|
||||
// commitRootedFileOverwrite publishes a replacement for an object whose
|
||||
// identity and preimage just passed validation: the bytes are written to a
|
||||
// side file beside the target, the observed identity is re-checked against the
|
||||
// path as late as the rename allows, and a rename hands the path over in one
|
||||
// step. A failure while the side file is being written — ENOSPC, a failing
|
||||
// device — therefore costs a removed side file, not the observed file.
|
||||
//
|
||||
// Refused renames fail without mutating the observed inode. Writing in place
|
||||
// here would expose partial content and rewrite hard-link siblings, defeating
|
||||
// the guarantees that staging provides.
|
||||
func commitRootedFileOverwrite(root *os.Root, absolutePath, relativePath, publishPath string, openedInfo os.FileInfo, mode os.FileMode, content string) error {
|
||||
tempName, err := writeRootedSideFile(root, publishPath, mode, false, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fileWriteBeforePublish != nil {
|
||||
fileWriteBeforePublish(absolutePath)
|
||||
}
|
||||
if err := verifyRootedPublishTarget(root, publishPath, openedInfo); err != nil {
|
||||
return errors.Join(err, removeWriteSideFile(root, tempName))
|
||||
}
|
||||
if err := fileWriteRename(root, tempName, publishPath); err != nil {
|
||||
renameErr := fmt.Errorf("writing %s: atomic replacement refused: %w", relativePath, err)
|
||||
return errors.Join(renameErr, removeWriteSideFile(root, tempName))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyRootedPublishTarget refuses to replace anything but the exact object
|
||||
// whose identity and preimage were validated. It is deliberately the last
|
||||
// operation before the rename; no portable rename offers a compare-and-swap,
|
||||
// so this check is the strongest condition available before an atomic replace.
|
||||
func verifyRootedPublishTarget(root *os.Root, publishPath string, openedInfo os.FileInfo) error {
|
||||
info, err := root.Lstat(publishPath)
|
||||
if err != nil || info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 || !os.SameFile(openedInfo, info) {
|
||||
return fmt.Errorf("%w: path identity changed", errFileChangedDuringWrite)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRootedFinalPath follows final-component symlinks inside the root and
|
||||
// returns the path of the file the caller's link resolves to. Publishing over
|
||||
// that path preserves the link, unlike a rename over the link itself.
|
||||
func resolveRootedFinalPath(root *os.Root, relativePath string) (string, error) {
|
||||
current := relativePath
|
||||
for links := 0; links < 255; links++ {
|
||||
info, err := root.Lstat(current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return current, nil
|
||||
}
|
||||
target, err := root.Readlink(current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if filepath.IsAbs(target) || filepath.VolumeName(target) != "" {
|
||||
return "", fmt.Errorf("%w: %s is an absolute link", errFileChangedDuringWrite, current)
|
||||
}
|
||||
next := filepath.Clean(filepath.Join(filepath.Dir(current), target))
|
||||
if next == ".." || strings.HasPrefix(next, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%w: %s points outside the workspace", errFileChangedDuringWrite, current)
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return "", fmt.Errorf("%w: more than 255 links at %s", errFileChangedDuringWrite, relativePath)
|
||||
}
|
||||
|
||||
// writeRootedSideFile writes content to a fresh side file beside the target,
|
||||
// relative to the root handle, and leaves it closed and ready to publish. The
|
||||
// caller owns removing the side file when the publish that follows does not
|
||||
// commit.
|
||||
//
|
||||
// umaskDerived selects the permission contract. A create asks the kernel for
|
||||
// 0o644 and remembers what the process umask actually granted; the bytes are
|
||||
// made owner-only while they are staged, then the granted bits are restored.
|
||||
// An overwrite stages 0o600 and finally carries the observed file's permission
|
||||
// bits over, so the published file looks like the one it replaced.
|
||||
func writeRootedSideFile(root *os.Root, relativePath string, mode os.FileMode, umaskDerived bool, content string) (string, error) {
|
||||
createMode := mode.Perm()
|
||||
if !umaskDerived {
|
||||
createMode = 0o600
|
||||
}
|
||||
temp, tempName, err := pathjail.CreateTempMode(root, filepath.Dir(relativePath), "zero-write", ".tmp", createMode)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("writing %s: %w", relativePath, err)
|
||||
}
|
||||
granted := mode.Perm()
|
||||
if umaskDerived {
|
||||
info, statErr := temp.Stat()
|
||||
if statErr != nil {
|
||||
_ = temp.Close()
|
||||
_ = removeWriteSideFile(root, tempName)
|
||||
return "", fmt.Errorf("writing %s: %w", relativePath, statErr)
|
||||
}
|
||||
granted = info.Mode().Perm()
|
||||
if chmodErr := temp.Chmod(0o600); chmodErr != nil {
|
||||
_ = temp.Close()
|
||||
_ = removeWriteSideFile(root, tempName)
|
||||
return "", fmt.Errorf("writing %s: %w", relativePath, chmodErr)
|
||||
}
|
||||
}
|
||||
writeErr := func() error {
|
||||
if err := fileWriteTempWrite(temp, content); err != nil {
|
||||
return err
|
||||
}
|
||||
return temp.Chmod(granted.Perm())
|
||||
}()
|
||||
closeErr := temp.Close()
|
||||
if writeErr != nil || closeErr != nil {
|
||||
_ = removeWriteSideFile(root, tempName)
|
||||
return "", fmt.Errorf("writing %s: %w", relativePath, errors.Join(writeErr, closeErr))
|
||||
}
|
||||
return tempName, nil
|
||||
}
|
||||
|
||||
// writeRootedFileInPlace publishes through an already-validated handle. It is
|
||||
// the non-regular path, so it keeps the previous truncate-and-write behaviour
|
||||
// and restores the observed bytes if a write fails and the preimage is available.
|
||||
func writeRootedFileInPlace(root *os.Root, relativePath string, file *os.File, openedInfo os.FileInfo, expectedContent *string, content string) error {
|
||||
if err := file.Truncate(0); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(file, content); err != nil {
|
||||
restoreErr := restoreRootedFile(file, expectedContent)
|
||||
closeErr := file.Close()
|
||||
return errors.Join(err, restoreErr, closeErr)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -113,3 +377,43 @@ func writeAndVerifyRootedFileIdentity(root *os.Root, relativePath string, file *
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreRootedFile puts the observed bytes back after a failed in-place
|
||||
// write. The write already truncated the file, so this is best effort: the
|
||||
// caller receives both the original failure and any restore failure.
|
||||
func restoreRootedFile(file *os.File, preimage *string) error {
|
||||
if preimage == nil {
|
||||
return nil
|
||||
}
|
||||
if err := file.Truncate(0); err != nil {
|
||||
return fmt.Errorf("restoring the preimage: %w", err)
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("restoring the preimage: %w", err)
|
||||
}
|
||||
if _, err := io.WriteString(file, *preimage); err != nil {
|
||||
return fmt.Errorf("restoring the preimage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeWriteSideFile removes a staged replacement that never reached (or did
|
||||
// not survive) its publish, through the root handle.
|
||||
func removeWriteSideFile(root *os.Root, name string) error {
|
||||
err := fileWriteRemove(root, name)
|
||||
if err == nil || errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
// Windows refuses to delete a read-only file. The staged file only carries
|
||||
// that bit after its published mode was applied; clear it through the root
|
||||
// and retry. The name belongs to a random side file this commit created.
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
if chmodErr := root.Chmod(name, 0o600); chmodErr == nil {
|
||||
err = fileWriteRemove(root, name)
|
||||
if err == nil || errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func installFileWriteRename(t *testing.T, rename func(*os.Root, string, string) error) {
|
||||
t.Helper()
|
||||
prior := fileWriteRename
|
||||
fileWriteRename = rename
|
||||
t.Cleanup(func() { fileWriteRename = prior })
|
||||
}
|
||||
|
||||
func installFileWriteBeforePublish(t *testing.T, hook func(string)) {
|
||||
t.Helper()
|
||||
prior := fileWriteBeforePublish
|
||||
fileWriteBeforePublish = hook
|
||||
t.Cleanup(func() { fileWriteBeforePublish = prior })
|
||||
}
|
||||
|
||||
func installFileWriteNoReplaceRename(t *testing.T, rename func(*os.Root, string, string) error) {
|
||||
t.Helper()
|
||||
prior := fileWriteNoReplaceRename
|
||||
fileWriteNoReplaceRename = rename
|
||||
t.Cleanup(func() { fileWriteNoReplaceRename = prior })
|
||||
}
|
||||
|
||||
// A FINAL-COMPONENT SYMLINK STAYS A LINK. Main opened the target through the
|
||||
// link and wrote through it, so replacing the link with a regular file leaves
|
||||
// the link's target stuck on its old bytes. Publishing over the resolved path
|
||||
// preserves both halves: the link is still a link, and the file it names has
|
||||
// the new content.
|
||||
//
|
||||
// The public tools reject a symlink during path validation today, so this is
|
||||
// asserted at the commit layer, which is where the publish semantics live and
|
||||
// where a scoped write that resolves through an extra root reaches them.
|
||||
func TestOverwriteKeepsAnInWorkspaceSymlink(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
chain bool
|
||||
}{
|
||||
{name: "direct link"},
|
||||
{name: "link chain", chain: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "target.txt")
|
||||
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "link.txt")
|
||||
if err := os.Symlink("target.txt", link); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
publishPath, links := "link.txt", []string{link}
|
||||
if tc.chain {
|
||||
chained := filepath.Join(root, "chained.txt")
|
||||
if err := os.Symlink("link.txt", chained); err != nil {
|
||||
t.Fatalf("create second symlink: %v", err)
|
||||
}
|
||||
publishPath, links = "chained.txt", append(links, chained)
|
||||
}
|
||||
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
expectedInfo, err := workspace.Stat(publishPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preimage := "observed\n"
|
||||
if err := commitRootedFileContents(workspace, filepath.Join(root, publishPath), publishPath, expectedInfo, &preimage, "zero\n"); err != nil {
|
||||
t.Fatalf("symlink overwrite: %v", err)
|
||||
}
|
||||
for _, path := range links {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect %s: %v", path, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("%s is no longer a symlink: mode=%v", path, info.Mode())
|
||||
}
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "zero\n" {
|
||||
t.Fatalf("link target = %q, err=%v; the link must keep pointing at the rewritten file", got, err)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".tmp") {
|
||||
t.Fatalf("staging file %s survived a successful publish", entry.Name())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An open reader keeps the observed inode. Platforms that cannot replace the
|
||||
// name while it is open must fail without rewriting that reader's bytes.
|
||||
func TestOverwriteWithAnOpenReaderPreservesAtomicPublication(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, err := os.Open(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "content": "zero\n", "overwrite": true,
|
||||
})
|
||||
want := "zero\n"
|
||||
if runtime.GOOS == "windows" && result.Status == StatusError && strings.Contains(result.Output, "atomic replacement refused") {
|
||||
want = "observed\n"
|
||||
} else if result.Status != StatusOK {
|
||||
t.Fatalf("overwrite with an open reader = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != want {
|
||||
t.Fatalf("target = %q, err=%v", got, err)
|
||||
}
|
||||
if got, err := io.ReadAll(reader); err != nil || string(got) != "observed\n" {
|
||||
t.Fatalf("open reader = %q, err=%v; publication must preserve the observed inode", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A refused atomic replacement must leave both names of the observed inode
|
||||
// untouched. Exercise both production callers and their session baselines.
|
||||
func TestOverwriteRefusedReplacementPreservesThePreimageAndHardLink(t *testing.T) {
|
||||
for _, name := range []string{"write", "edit"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
sibling := filepath.Join(root, "sibling.txt")
|
||||
preimage := []byte("observed\n")
|
||||
if err := os.WriteFile(target, preimage, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Link(target, sibling); err != nil {
|
||||
t.Skipf("hard links unavailable: %v", err)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker := NewFileTracker()
|
||||
tracker.Record(target, preimage, info)
|
||||
tracker.RecordSeenRange(target, 1, 1, 1)
|
||||
installFileWriteRename(t, func(*os.Root, string, string) error {
|
||||
return errors.New("replacement refused")
|
||||
})
|
||||
tool := NewScopedWriteFileTool(root, nil)
|
||||
args := map[string]any{"path": "existing.txt", "content": "zero\n", "overwrite": true}
|
||||
if name == "edit" {
|
||||
tool = NewScopedEditFileTool(root, nil)
|
||||
args = map[string]any{"path": "existing.txt", "old_string": "observed", "new_string": "zero"}
|
||||
}
|
||||
registry := NewRegistry()
|
||||
registry.Register(tool)
|
||||
result := registry.RunWithOptions(context.Background(), tool.Name(), args, RunOptions{FileTracker: tracker, PermissionGranted: true})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "replacement refused") {
|
||||
t.Fatalf("refused overwrite = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
for _, path := range []string{target, sibling} {
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != string(preimage) {
|
||||
t.Fatalf("%s = %q, err=%v; a refused publish must preserve the shared preimage", path, got, err)
|
||||
}
|
||||
}
|
||||
if err := tracker.CheckConflict(target, preimage); err != nil {
|
||||
t.Fatalf("tracker changed after refused publication: %v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil || len(entries) != 2 {
|
||||
t.Fatalf("refused publish left staging files: %v, err=%v", entries, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A competing writer that replaces the path during a refused rename keeps its
|
||||
// own file. No in-place write may follow the failed publication.
|
||||
func TestOverwriteRefusedRenameLeavesACompetingReplacementUntouched(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installFileWriteRename(t, func(*os.Root, string, string) error {
|
||||
preserved := target + ".preserved"
|
||||
if err := os.Rename(target, preserved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return errors.New("replacement refused")
|
||||
})
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "content": "zero\n", "overwrite": true,
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "replacement refused") {
|
||||
t.Fatalf("replaced target = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "observed\n" {
|
||||
t.Fatalf("replacement = %q, err=%v; a refused rename must leave the competing file untouched", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A FILESYSTEM MAY NOT OFFER A NO-REPLACE RENAME. The hard-link fallback is
|
||||
// still atomic against readers — a reader sees the absent name or the complete
|
||||
// inode — and refuses a name another writer created.
|
||||
func TestCreateFallsBackToAHardLinkWhenNoReplaceRenameIsUnsupported(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "created.txt")
|
||||
installFileWriteNoReplaceRename(t, func(*os.Root, string, string) error {
|
||||
return errors.ErrUnsupported
|
||||
})
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "created.txt", "content": "zero\n",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("hard-link create = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "zero\n" {
|
||||
t.Fatalf("created file = %q, err=%v", got, err)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != "created.txt" {
|
||||
t.Fatalf("root holds %d entries; the staging name must not survive the publish", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHardLinkFallbackStillRefusesAnExistingName(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
if err := os.WriteFile(target, []byte("other writer\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installFileWriteNoReplaceRename(t, func(*os.Root, string, string) error {
|
||||
return errors.ErrUnsupported
|
||||
})
|
||||
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
if err := commitRootedFileContents(workspace, target, "existing.txt", nil, nil, "zero\n"); !errors.Is(err, os.ErrExist) {
|
||||
t.Fatalf("hard-link create over an existing name = %v, want os.ErrExist", err)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "other writer\n" {
|
||||
t.Fatalf("existing file = %q, err=%v; the fallback must not replace it", got, err)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != "existing.txt" {
|
||||
t.Fatalf("root holds %d entries; a refused create must remove its staging file", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// A REPLACEMENT INSTALLED AFTER THE BYTES WERE STAGED must not be silently
|
||||
// overwritten by the publish. The identity check immediately before the rename
|
||||
// is what keeps the conflict guarantee for the window that remains.
|
||||
func TestOverwriteRefusesAReplacementInstalledBeforePublish(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installFileWriteBeforePublish(t, func(path string) {
|
||||
preserved := path + ".preserved"
|
||||
if err := os.Rename(path, preserved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("competing\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "content": "zero\n", "overwrite": true,
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, errFileChangedDuringWrite.Error()) {
|
||||
t.Fatalf("pre-publish replacement = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "competing\n" {
|
||||
t.Fatalf("competing file = %q, err=%v; the publish must refuse a replacement it did not observe", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Publication and cleanup are separate outcomes. Even when cleanup fails, the
|
||||
// session must know that the new file exists and contains the requested bytes.
|
||||
func TestCreateCleanupFailureRecordsThePublishedFile(t *testing.T) {
|
||||
for _, cleanupErr := range []error{errors.New("injected staging cleanup failure"), os.ErrPermission} {
|
||||
t.Run(cleanupErr.Error(), func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(root, "created.txt")
|
||||
var publishedMode os.FileMode
|
||||
installFileWriteNoReplaceRename(t, func(root *os.Root, oldName, _ string) error {
|
||||
info, err := root.Stat(oldName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publishedMode = info.Mode().Perm()
|
||||
return errors.ErrUnsupported
|
||||
})
|
||||
prior := fileWriteRemove
|
||||
fileWriteRemove = func(*os.Root, string) error { return cleanupErr }
|
||||
t.Cleanup(func() { fileWriteRemove = prior })
|
||||
|
||||
tracker := NewFileTracker()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedWriteFileTool(root, nil))
|
||||
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
||||
"path": "created.txt", "content": "zero\n",
|
||||
}, RunOptions{FileTracker: tracker, PermissionGranted: true})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "published") || !strings.Contains(result.Output, cleanupErr.Error()) {
|
||||
t.Fatalf("published create with cleanup failure = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "zero\n" {
|
||||
t.Fatalf("published content = %q, err=%v", got, err)
|
||||
}
|
||||
if info, err := os.Stat(target); err != nil || info.Mode().Perm() != publishedMode {
|
||||
t.Fatalf("published mode changed after cleanup failure: %v, err=%v, want %v", info, err, publishedMode)
|
||||
}
|
||||
if version, ok := tracker.Version(target); !ok || version.Hash != HashContent([]byte("zero\n")) {
|
||||
t.Fatalf("published file missing from tracker: %+v, tracked=%v", version, ok)
|
||||
}
|
||||
if !tracker.SeenWhole(target) {
|
||||
t.Fatal("published bytes must be recorded as seen")
|
||||
}
|
||||
if created := tracker.CreatedFiles(); len(created) != 1 || created[0] != target {
|
||||
t.Fatalf("created files = %v, want %s", created, target)
|
||||
}
|
||||
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "created.txt" {
|
||||
t.Fatalf("changed files = %v", result.ChangedFiles)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -350,3 +350,71 @@ func TestWriteFileRefusesSameNameSubstitutionWithIdenticalBytes(t *testing.T) {
|
||||
t.Fatalf("substituted object = %q, err=%v; the write must not land in an object the preimage did not observe", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AN OVERWRITTEN PATH'S NEIGHBOURS THROUGH ANOTHER NAME KEEP THEIR CONTENT.
|
||||
//
|
||||
// The replace-overwrite truncates and rewrites the observed file in place, and
|
||||
// truncation follows the inode: a sibling reached through a hard link loses
|
||||
// its content when this path's content is replaced, because both names were
|
||||
// pointing at the same bytes. Publishing through a rename detaches this name
|
||||
// from the shared inode instead, so the link sibling keeps what it shares.
|
||||
func TestOverwriteDoesNotTruncateAHardlinkSibling(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "target.txt")
|
||||
sibling := filepath.Join(root, "sibling.txt")
|
||||
if err := os.WriteFile(target, []byte("shared\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Link(target, sibling); err != nil {
|
||||
t.Skipf("hard links unavailable: %v", err)
|
||||
}
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "target.txt", "content": "replaced\n", "overwrite": true,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("overwrite status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
shared, err := os.ReadFile(sibling)
|
||||
if err != nil || string(shared) != "shared\n" {
|
||||
t.Fatalf("hard-link sibling = %q, err=%v; replacing this name must not rewrite the bytes the sibling shares", shared, err)
|
||||
}
|
||||
}
|
||||
|
||||
// THE PUBLISHED FILE LOOKS LIKE THE FILE IT REPLACED. The permission bits are
|
||||
// carried over from the file that was observed, not from the side file the
|
||||
// bytes were staged in.
|
||||
func TestOverwriteKeepsTheObservedPermissionBits(t *testing.T) {
|
||||
// 0o640 differs from the 0o600 the side file is staged with, so a publish
|
||||
// that forgot to carry the observed bits cannot pass by coincidence.
|
||||
want := os.FileMode(0o640)
|
||||
if runtime.GOOS == "windows" {
|
||||
// A Windows file carries only the read-only bit, so a 0o640 file is
|
||||
// reported as 0o666; the write bit, not the exact value, is the fact
|
||||
// being pinned.
|
||||
want = os.FileMode(0o666)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
if err := os.WriteFile(target, []byte("observed\n"), want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(target, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "content": "zero\n", "overwrite": true,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("overwrite status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != want {
|
||||
t.Fatalf("published permission bits = %o, want %o", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build unix
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE PUBLISHED FILE CARRIES THE PERMISSIONS THE PROCESS UMASK PRODUCES, NOT
|
||||
// THE RAW MODE AN ASSIGNMENT WOULD SET.
|
||||
//
|
||||
// The create path opens its staging file with the same exclusive 0o644 create
|
||||
// this branch of the write path always honored, and the kernel applies the
|
||||
// process umask to that open. The publish must carry the granted bits — taken
|
||||
// from the staging file itself — because a chmod never consults the umask: a
|
||||
// process that requests tight defaults on new files would find every write it
|
||||
// publishes shared with the world. Under the restrictive umask a plain mode
|
||||
// assignment would publish 0o644 where the kernel granted 0o600, so the test
|
||||
// fails before the granted bits are carried.
|
||||
func TestCreatePublishesTheUmaskedPermissionsOfTheStagedCreate(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
umask int
|
||||
want os.FileMode
|
||||
}{
|
||||
"a restrictive umask keeps every stranger out": {umask: 0o077, want: 0o600},
|
||||
"a permissive umask keeps the readable bits": {umask: 0o022, want: 0o644},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
previous := syscall.Umask(testCase.umask)
|
||||
defer syscall.Umask(previous)
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "created.txt")
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "created.txt", "content": "zero\n",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("create status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != testCase.want {
|
||||
t.Fatalf("published mode = %#o, want the %#o the kernel granted the staging create under this umask; a raw mode assignment would publish %#o and share the file with hands the umask excludes", got, testCase.want, os.FileMode(0o644))
|
||||
}
|
||||
if got, readErr := os.ReadFile(target); readErr != nil || string(got) != "zero\n" {
|
||||
t.Fatalf("published content = %q, err=%v", got, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func installFileWriteTempWrite(t *testing.T, write func(*os.File, string) error) {
|
||||
t.Helper()
|
||||
prior := fileWriteTempWrite
|
||||
fileWriteTempWrite = write
|
||||
t.Cleanup(func() { fileWriteTempWrite = prior })
|
||||
}
|
||||
|
||||
// A WRITE THAT CANNOT COMPLETE MUST NOT TAKE THE FILE IT REPLACED WITH IT.
|
||||
//
|
||||
// Running out of disk space part-way through a write used to be fatal to the
|
||||
// preimage: the overwrite truncated the observed file first, so the failed
|
||||
// write left an empty husk where a working file stood. The replacement bytes
|
||||
// are now written whole to a side file, so a failure there costs the side file
|
||||
// and the observed bytes survive untouched. Each case reopens the target after
|
||||
// the tool has run and checks the preimage is what still stands, and that no
|
||||
// side file was left behind.
|
||||
func TestOverwriteFailureKeepsThePreimageStanding(t *testing.T) {
|
||||
for name, run := range map[string]func(string) Result{
|
||||
"write overwrite": func(root string) Result {
|
||||
return NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "content": "zero\n", "overwrite": true,
|
||||
})
|
||||
},
|
||||
"edit": func(root string) Result {
|
||||
return NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "old_string": "observed", "new_string": "zero",
|
||||
})
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The write that stands in for ENOSPC fails after the file was
|
||||
// observed, accepted and staged — the exact point the old
|
||||
// truncate-then-write had nothing left to lose.
|
||||
writeErr := errors.New("injected unrecoverable write failure")
|
||||
installFileWriteTempWrite(t, func(*os.File, string) error { return writeErr })
|
||||
|
||||
result := run(root)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("failed-write overwrite = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
got, err := os.ReadFile(target)
|
||||
if err != nil || string(got) != "observed\n" {
|
||||
t.Fatalf("preimage = %q, err=%v; the observed file must outlive a write that failed", got, err)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != "existing.txt" {
|
||||
t.Fatalf("root holds %d entries; a failed write must remove its side file", len(entries))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A NEW FILE WHOSE WRITE FAILS MUST NOT APPEAR AT ALL. An exclusive create used
|
||||
// to leave whatever the write had managed to write behind, half-written and
|
||||
// complete-looking to anything that did not read past the visible length.
|
||||
func TestCreateFailureCreatesNothing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "created.txt")
|
||||
writeErr := errors.New("injected unrecoverable write failure")
|
||||
installFileWriteTempWrite(t, func(*os.File, string) error { return writeErr })
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "created.txt", "content": "zero\n",
|
||||
})
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("failed-write create = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("target = %v; a create whose bytes never made it must leave no file", statErr)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("root holds %d entries; a failed create must remove its side file", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// AT THE LAYER THE COMMIT RUNS, the injected failure is reported as the
|
||||
// failure it injected and nothing else, so a caller can distinguish a failed
|
||||
// write from a refused write.
|
||||
func TestCommitRootedOverwriteFailedWriteIsTheWriteError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preimage := "observed\n"
|
||||
writeErr := errors.New("injected unrecoverable write failure")
|
||||
installFileWriteTempWrite(t, func(*os.File, string) error { return writeErr })
|
||||
|
||||
err = commitRootedFileContents(workspace, filepath.Join(root, "existing.txt"), "existing.txt", info, &preimage, "zero\n")
|
||||
if !errors.Is(err, writeErr) {
|
||||
t.Fatalf("commit error = %v, want the injected write failure", err)
|
||||
}
|
||||
got, readErr := os.ReadFile(target)
|
||||
if readErr != nil || string(got) != "observed\n" {
|
||||
t.Fatalf("preimage = %q, err=%v; the observed file must outlive a write that failed", got, readErr)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -146,8 +147,9 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
if priorContentKnown {
|
||||
expectedContent = &priorContent
|
||||
}
|
||||
if err := commitRootedFileContents(root, absolutePath, rootedRelative, priorInfo, expectedContent, content); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
commitErr := commitRootedFileContents(root, absolutePath, rootedRelative, priorInfo, expectedContent, content)
|
||||
if commitErr != nil && !errors.Is(commitErr, errFilePublishedWithCleanupFailure) {
|
||||
return errorResult("Error writing file " + relativePath + ": " + commitErr.Error())
|
||||
}
|
||||
modelKnownContent := content
|
||||
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
||||
@@ -193,7 +195,13 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
if finalContentKnown {
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
}
|
||||
if commitErr != nil {
|
||||
summary += "\n" + commitErr.Error()
|
||||
}
|
||||
result := okResult(summary)
|
||||
if commitErr != nil {
|
||||
result.Status = StatusError
|
||||
}
|
||||
result.ChangedFiles = []string{relativePath}
|
||||
// Do not pretend an unreadable overwrite was a creation. The write may be
|
||||
// valid, but ACP only receives an exact before/after pair we actually saw.
|
||||
|
||||
@@ -6351,7 +6351,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
flushReasoning(m.now())
|
||||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||||
Type: sessions.EventError,
|
||||
Payload: sessions.ErrorEventPayload(err),
|
||||
Payload: map[string]any{"message": err.Error()},
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, usageModelIDs: usageModelIDs, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// A provider error reaches the TUI session log with its HTTP status and the
|
||||
// upstream cause, not only the flattened message (#674).
|
||||
func TestPromptSubmitRecordsTheProviderErrorStatusAndCause(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
provider := &fakeProvider{events: []zeroruntime.StreamEvent{{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: "provider request error: model not found",
|
||||
StatusCode: 404,
|
||||
Cause: "upstream said: no such model",
|
||||
}}}
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: isolatedCwd(t),
|
||||
ProviderName: "openai",
|
||||
ModelName: "gpt-4.1",
|
||||
Provider: provider,
|
||||
Registry: tools.NewRegistry(),
|
||||
SessionStore: store,
|
||||
})
|
||||
m.input.SetValue("inspect repo")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected prompt submit to start an agent run")
|
||||
}
|
||||
updated, _ = next.Update(execCmd(cmd))
|
||||
_ = updated.(model)
|
||||
|
||||
list, err := store.List()
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("sessions = %d, %v; want one", len(list), err)
|
||||
}
|
||||
events, err := store.ReadEvents(list[0].SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if got := countSessionEvents(events, sessions.EventError); got != 1 {
|
||||
t.Fatalf("recorded %d error events, want 1: %#v", got, eventTypes(events))
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(nthSessionEvent(t, events, sessions.EventError, 1).Payload, &payload); err != nil {
|
||||
t.Fatalf("unmarshal error payload: %v", err)
|
||||
}
|
||||
if got, want := payload["statusCode"], float64(404); got != want {
|
||||
t.Errorf("statusCode = %#v, want %v: the run recorded only the flattened message", got, want)
|
||||
}
|
||||
if got, want := payload["cause"], "upstream said: no such model"; got != want {
|
||||
t.Errorf("cause = %#v, want %q: the run recorded only the flattened message", got, want)
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,10 @@ import (
|
||||
|
||||
// CollectedStream is the non-streaming summary of provider events.
|
||||
type CollectedStream struct {
|
||||
Text string
|
||||
ToolCalls []ToolCall
|
||||
Usage Usage
|
||||
Error string
|
||||
// ErrorStatusCode and ErrorCause mirror StreamEvent.StatusCode/Cause for a
|
||||
// StreamEventError — see those fields for what each carries. Both are zero
|
||||
// value when Error came from a context cancellation or other non-HTTP source.
|
||||
ErrorStatusCode int
|
||||
ErrorCause string
|
||||
Text string
|
||||
ToolCalls []ToolCall
|
||||
Usage Usage
|
||||
Error string
|
||||
DroppedToolCalls int // malformed tool calls the provider could not dispatch
|
||||
// FinishReason is the provider's normalized terminal stop reason when the
|
||||
// response did not end normally (FinishReasonLength / FinishReasonContentFilter).
|
||||
@@ -158,8 +153,6 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
|
||||
usageSeen = true
|
||||
case StreamEventError:
|
||||
collected.Error = event.Error
|
||||
collected.ErrorStatusCode = event.StatusCode
|
||||
collected.ErrorCause = event.Cause
|
||||
return finish()
|
||||
case StreamEventDone:
|
||||
return finish()
|
||||
|
||||
@@ -212,17 +212,6 @@ type StreamEvent struct {
|
||||
// available. Stateful turn sessions use it to chain a later compatible
|
||||
// request; ordinary providers and consumers leave it empty.
|
||||
ResponseID string
|
||||
// StatusCode is the HTTP status code behind Error, when the failure came from
|
||||
// an HTTP response (0 for transport-level/context errors that never received
|
||||
// one). Providers set it alongside Error on a StreamEventError so the status
|
||||
// survives past the flattened error string (#674).
|
||||
StatusCode int
|
||||
// Cause is the raw, secret-redacted upstream detail behind Error — the
|
||||
// provider's own error message or response body — kept separate from Error's
|
||||
// curated/classified wording so both are preserved rather than one
|
||||
// overwriting the other. Always passed through the same redaction already
|
||||
// applied to Error (see provider.redact); never stored unredacted.
|
||||
Cause string
|
||||
// FinishReason carries the provider's normalized terminal stop reason when a
|
||||
// response did not end normally (e.g. FinishReasonLength when the output hit
|
||||
// the token cap, or FinishReasonContentFilter when it was filtered). It is
|
||||
@@ -234,20 +223,6 @@ type StreamEvent struct {
|
||||
ReasoningBlocks []ReasoningBlock
|
||||
}
|
||||
|
||||
// StreamError is the terminal error returned for a failed provider stream. It
|
||||
// implements error via Message, so existing `err.Error()` call sites are
|
||||
// unaffected by this type; callers that need the underlying HTTP status code or
|
||||
// upstream cause recover them with errors.As instead of re-parsing the message
|
||||
// string. This is what lets a session's "error" event persist real diagnostic
|
||||
// detail instead of only the flattened message (#674).
|
||||
type StreamError struct {
|
||||
Message string
|
||||
StatusCode int
|
||||
Cause string
|
||||
}
|
||||
|
||||
func (e *StreamError) Error() string { return e.Message }
|
||||
|
||||
// CompletionRequest groups provider input messages and available tools.
|
||||
type CompletionRequest struct {
|
||||
Messages []Message
|
||||
|
||||
Reference in new issue
Block a user