mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* feat: agent quality, caching, retry, and tooling upgrades Ten self-contained improvements to the core agent engine and tools: - edit_file: fuzzy fallback matching (7-strategy cascade with uniqueness protection and a disproportion guard) when old_string does not match byte-for-byte; exact match remains the fast path - edit_file/write_file: inline error-severity LSP diagnostics in tool output so the model sees a break it introduced in the same turn - lsp: expand server registry from 4 to 28 languages; doctor grades only tier-1 servers and lists the long tail informationally - agent: consecutive read-only tool calls execute concurrently (bounded at 8), consumed in original order, never spanning a mutating call - anthropic: cache_control breakpoints on the last two conversation messages so the transcript prefix stays a prompt-cache hit - providerio: retry schedule raised to 6 attempts with exponential 2s-30s backoff when the server sends no Retry-After - tools: truncated exec/bash output spills to a redacted temp file referenced in the truncation notice; bash output (previously unbounded) now shares exec_command's token budget - web_fetch: dependency-free HTML-to-markdown conversion (format: auto|raw|markdown) and larger limits (256KiB default, 2MiB max, 30s) - modelregistry: live models.dev overlay refreshes context limits and base pricing from a TTL-cached fetch; opt-in at the CLI entrypoint - tools: opt-in format-on-write (ZERO_FORMAT_ON_WRITE=1) runs standard formatters across 27 extensions, ordered before the file-tracker re-baseline so conflict detection is preserved * fix: address PR review feedback on fuzzy edits, diagnostics, spill - edit_file: adapt the replacement to the fuzzy-resolved span — when the span is a uniformly deeper-indented copy of old_string (line-trimmed / indentation-flexible matches), the same indentation delta is applied to new_string, and a span ending in a bare CR keeps its CR so CRLF files never gain a mixed line ending; block-anchor matches with drifted interiors are deliberately left untouched - diagnostics: render workspace-relative paths in inline post-edit diagnostics (base name outside the workspace) so absolute paths never leak the local username or directory layout into the model prompt or session transcript - spill: harden the spill directory for shared temp dirs — per-uid name, reject symlinked or non-directory paths (MkdirAll follows symlinks), and on Unix require current-user ownership - bash: the branch's output cap and truncation-flag threading are superseded by main's capture-time bounding (boundedBuffer + budgetBashCapture), which already sets Result.Truncated; this rebase adopts that design and keeps spill-to-disk on exec_command, where the full output is still buffered and a spill is genuinely complete * fix: address review round 2 — permission-event race, fuzzy ambiguity, spill reachability - agent: serialize OnPermission behind the same mutex as OnPermissionRequest in the parallel read batch. buildPermissionEvent fires for batched reads with a non-trivial sandbox decision (e.g. a grant-matched extra root), and front-end handlers append to shared session-recording state without their own locking — two batched reads emitting concurrently was a data race the pre-batch serial loop never allowed. - edit_file: reject two or more DISTINCT candidate spans from one fuzzy strategy as ambiguous instead of silently editing the first. Duplicate blocks at different indentation each resolve to a unique literal, so the previous occurrence-uniqueness check passed and wrote the wrong span. A single candidate occurring at multiple positions still cascades to stricter strategies as before. - tools: make spill files actually reachable by the recovery path the truncation notice advertises — resolveScopedReadPath now recognizes files inside the per-uid spill dir (containment verified after symlink resolution, so a planted link cannot smuggle an out-of-scope file through the gate); read_file/grep can follow the notice instead of re-running the command. * fix: surface competing block-anchor spans; make write-barrier test observe cross-probe order - edit_file: blockAnchorReplacer now returns every span clearing the similarity threshold (best first) instead of silently picking the single most-similar block. Hiding competitors from the cascade's distinct-candidate check could edit the wrong block when two blocks share anchors and both interiors are plausible; one clear winner still resolves, two plausible blocks now report ambiguity (covered by a two-block test that asserts the file is untouched). - agent tests: the parallel write-barrier test now records both probes into one mutex-guarded shared log and asserts the cross-probe ordering it always claimed to (write starts after the first read batch ends; second read batch starts after the write ends). The previous version only compared indices within the read probe's own log, which a regression letting the write overlap a batch could still pass; the stray no-op mutex assignment is gone.
402 lines
12 KiB
Go
402 lines
12 KiB
Go
package tools
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/workspaceindex"
|
|
)
|
|
|
|
var ignoredDirectories = map[string]bool{
|
|
".git": true,
|
|
"node_modules": true,
|
|
"dist": true,
|
|
"build": true,
|
|
".next": true,
|
|
".turbo": true,
|
|
"coverage": true,
|
|
".cache": true,
|
|
"tmp": true,
|
|
"temp": true,
|
|
}
|
|
|
|
func normalizeWorkspaceRoot(workspaceRoot string) string {
|
|
root, err := filepath.Abs(workspaceRoot)
|
|
if err != nil {
|
|
return workspaceRoot
|
|
}
|
|
return root
|
|
}
|
|
|
|
func resolveWorkspacePath(workspaceRoot string, requestedPath string) (string, string, error) {
|
|
if requestedPath == "" {
|
|
requestedPath = "."
|
|
}
|
|
|
|
root, err := filepath.Abs(workspaceRoot)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
root, err = filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
target := requestedPath
|
|
if !filepath.IsAbs(target) {
|
|
target = filepath.Join(root, target)
|
|
}
|
|
|
|
target, err = filepath.Abs(target)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
target, err = filepath.EvalSymlinks(target)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
relative, err := filepath.Rel(root, target)
|
|
if err != nil {
|
|
return "", "", outsideWorkspaceError(requestedPath)
|
|
}
|
|
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
|
return "", "", outsideWorkspaceError(requestedPath)
|
|
}
|
|
if relative == "." {
|
|
return target, ".", nil
|
|
}
|
|
return target, filepath.ToSlash(relative), nil
|
|
}
|
|
|
|
func resolveWorkspaceTargetPath(workspaceRoot string, requestedPath string) (string, string, error) {
|
|
if requestedPath == "" {
|
|
requestedPath = "."
|
|
}
|
|
|
|
root, err := filepath.Abs(workspaceRoot)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
root, err = filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
target := requestedPath
|
|
if !filepath.IsAbs(target) {
|
|
target = filepath.Join(root, target)
|
|
}
|
|
target, err = filepath.Abs(target)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := recheckWorkspaceWriteTarget(root, target); err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
existing := target
|
|
missingSegments := []string{}
|
|
for {
|
|
if _, err := os.Lstat(existing); err == nil {
|
|
break
|
|
} else if os.IsNotExist(err) {
|
|
parent := filepath.Dir(existing)
|
|
if parent == existing {
|
|
return "", "", err
|
|
}
|
|
missingSegments = append([]string{filepath.Base(existing)}, missingSegments...)
|
|
existing = parent
|
|
continue
|
|
} else {
|
|
return "", "", err
|
|
}
|
|
}
|
|
|
|
resolved, err := filepath.EvalSymlinks(existing)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
for _, segment := range missingSegments {
|
|
resolved = filepath.Join(resolved, segment)
|
|
}
|
|
|
|
relative, err := filepath.Rel(root, resolved)
|
|
if err != nil {
|
|
return "", "", outsideWorkspaceError(requestedPath)
|
|
}
|
|
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
|
return "", "", outsideWorkspaceError(requestedPath)
|
|
}
|
|
if relative == "." {
|
|
return resolved, ".", nil
|
|
}
|
|
return resolved, filepath.ToSlash(relative), nil
|
|
}
|
|
|
|
func recheckWorkspaceWriteTarget(workspaceRoot string, requestedPath string) error {
|
|
if requestedPath == "" {
|
|
requestedPath = "."
|
|
}
|
|
|
|
root, err := filepath.Abs(workspaceRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
root, err = filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
target := requestedPath
|
|
if !filepath.IsAbs(target) {
|
|
target = filepath.Join(root, target)
|
|
}
|
|
target, err = filepath.Abs(target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
relative, err := filepath.Rel(root, target)
|
|
if err != nil {
|
|
return outsideWorkspaceError(requestedPath)
|
|
}
|
|
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
|
return outsideWorkspaceError(requestedPath)
|
|
}
|
|
if relative == "." {
|
|
return nil
|
|
}
|
|
|
|
current := root
|
|
for _, segment := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) {
|
|
if segment == "." || segment == "" {
|
|
continue
|
|
}
|
|
|
|
current = filepath.Join(current, segment)
|
|
info, err := os.Lstat(current)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
symlinkRelative, err := filepath.Rel(root, current)
|
|
if err != nil {
|
|
symlinkRelative = current
|
|
}
|
|
return fmt.Errorf("%s must not traverse symlink %s", requestedPath, filepath.ToSlash(symlinkRelative))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func outsideWorkspaceError(requestedPath string) error {
|
|
return fmt.Errorf("%s must stay inside the workspace", requestedPath)
|
|
}
|
|
|
|
func shouldSkipDirectory(name string) bool {
|
|
return ignoredDirectories[name] || workspaceindex.ShouldSkipDir(name)
|
|
}
|
|
|
|
func shouldSkipWorkspaceFile(path string) bool {
|
|
return workspaceindex.ShouldSkipFile(path)
|
|
}
|
|
|
|
// PathScope is the multi-root write scope shared with the sandbox engine.
|
|
// *sandbox.Scope satisfies it; nil means workspace-only (today's behavior).
|
|
// Roots()[0] must be the workspace root (sandbox.Scope guarantees this
|
|
// ordering); relative paths and error messages key off it.
|
|
type PathScope interface {
|
|
Roots() []string
|
|
}
|
|
|
|
type readPathScope interface {
|
|
ReadRoots() []string
|
|
}
|
|
|
|
// scopedRoots returns the ordered roots to try for an absolute path: the
|
|
// scope's roots when present, else just the workspace root. A non-nil scope
|
|
// must expose at least one root (the workspace root first, per PathScope); an
|
|
// empty Roots() is a contract block that fails closed with an error so the
|
|
// scoped helpers never silently accept a path with no root to validate against.
|
|
// Returning success there would resolve to an empty path and, e.g., run bash
|
|
// with Cmd.Dir == "" (the process cwd) instead of an allowed root.
|
|
func scopedRoots(workspaceRoot string, scope PathScope) ([]string, error) {
|
|
if scope == nil {
|
|
return []string{workspaceRoot}, nil
|
|
}
|
|
roots := scope.Roots()
|
|
if len(roots) == 0 {
|
|
return nil, fmt.Errorf("invalid path scope: no write roots configured")
|
|
}
|
|
return roots, nil
|
|
}
|
|
|
|
func scopedReadRoots(workspaceRoot string, scope PathScope) ([]string, error) {
|
|
if scope == nil {
|
|
return []string{workspaceRoot}, nil
|
|
}
|
|
if reader, ok := scope.(readPathScope); ok {
|
|
roots := reader.ReadRoots()
|
|
if len(roots) == 0 {
|
|
return nil, fmt.Errorf("invalid path scope: no read roots configured")
|
|
}
|
|
return roots, nil
|
|
}
|
|
return scopedRoots(workspaceRoot, scope)
|
|
}
|
|
|
|
func resolveScopedReadPath(workspaceRoot string, scope PathScope, requestedPath string) (string, string, error) {
|
|
// Spill files (truncated tool output saved under the per-uid temp dir) are
|
|
// readable regardless of scope: the truncation notice tells the model to
|
|
// read_file/grep them, which must actually work. resolveSpillReadPath
|
|
// verifies containment after symlink resolution, so this cannot be used to
|
|
// reach anything outside the spill dir.
|
|
if spillPath, ok := resolveSpillReadPath(requestedPath); ok {
|
|
return spillPath, spillPath, nil
|
|
}
|
|
if requestedPath == "" || !filepath.IsAbs(requestedPath) || scope == nil {
|
|
return resolveWorkspacePath(workspaceRoot, requestedPath)
|
|
}
|
|
roots, err := scopedReadRoots(workspaceRoot, scope)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
var firstErr error
|
|
for index, root := range roots {
|
|
candidate := sandbox.NormalizePrefixForRoot(requestedPath, root)
|
|
absolute, relative, err := resolveWorkspacePath(root, candidate)
|
|
if err == nil {
|
|
if index > 0 {
|
|
return absolute, absolute, nil
|
|
}
|
|
return absolute, relative, nil
|
|
}
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return "", "", firstErr
|
|
}
|
|
|
|
// resolveScopedPath is resolveWorkspacePath generalized to a scope: relative
|
|
// paths resolve against the workspace root only; an absolute path resolves
|
|
// against the first root that contains it. The workspace root's error is
|
|
// returned when no root matches so messages stay stable. Prefix symlinks
|
|
// outside a root are resolved per root (macOS /var aliasing); symlinks INSIDE
|
|
// a root stay visible to the single-root checks, so a write target may resolve
|
|
// through a symlink only when its final location lies inside a DIFFERENT
|
|
// granted root — mirroring sandbox.Scope.validate's documented widening.
|
|
// When the matched root is an extra (non-workspace) root the second return
|
|
// value is the absolute path rather than a per-root-relative path; "relative
|
|
// to which root" is ambiguous for downstream consumers (ChangedFiles, cwd
|
|
// meta, display summaries) that document workspace-relative paths.
|
|
// When all roots deny, the workspace root's error is returned; unlike
|
|
// sandbox.Scope.validate this does not prefer traversal blocks — the
|
|
// engine layer reports those with full fidelity before tools run.
|
|
func resolveScopedPath(workspaceRoot string, scope PathScope, requestedPath string) (string, string, error) {
|
|
if requestedPath == "" || !filepath.IsAbs(requestedPath) || scope == nil {
|
|
return resolveWorkspacePath(workspaceRoot, requestedPath)
|
|
}
|
|
roots, err := scopedRoots(workspaceRoot, scope)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
var firstErr error
|
|
for index, root := range roots {
|
|
// Normalize platform-level symlinks (e.g. macOS /var -> /private/var)
|
|
// in the prefix outside this root only, leaving in-root components
|
|
// verbatim for the single-root symlink checks.
|
|
candidate := sandbox.NormalizePrefixForRoot(requestedPath, root)
|
|
absolute, relative, err := resolveWorkspacePath(root, candidate)
|
|
if err == nil {
|
|
if index > 0 {
|
|
// Extra-root matches report the absolute path: "relative to
|
|
// which root" is ambiguous downstream (ChangedFiles, cwd
|
|
// meta, display), and consumers document workspace-relative.
|
|
return absolute, absolute, nil
|
|
}
|
|
return absolute, relative, nil
|
|
}
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return "", "", firstErr
|
|
}
|
|
|
|
// resolveScopedTargetPath mirrors resolveWorkspaceTargetPath for write targets
|
|
// (the target may not exist yet) across all scope roots. When the matched root
|
|
// is an extra (non-workspace) root the second return value is the absolute
|
|
// path rather than a per-root-relative path; "relative to which root" is
|
|
// ambiguous for downstream consumers (ChangedFiles, cwd meta, display
|
|
// summaries) that document workspace-relative paths.
|
|
// When all roots deny, the workspace root's error is returned; unlike
|
|
// sandbox.Scope.validate this does not prefer traversal blocks — the
|
|
// engine layer reports those with full fidelity before tools run.
|
|
func resolveScopedTargetPath(workspaceRoot string, scope PathScope, requestedPath string) (string, string, error) {
|
|
if requestedPath == "" || !filepath.IsAbs(requestedPath) || scope == nil {
|
|
return resolveWorkspaceTargetPath(workspaceRoot, requestedPath)
|
|
}
|
|
roots, err := scopedRoots(workspaceRoot, scope)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
var firstErr error
|
|
for index, root := range roots {
|
|
// Normalize platform-level symlinks (e.g. macOS /var -> /private/var)
|
|
// in the prefix outside this root only; in-root components stay
|
|
// verbatim so the per-segment write-target symlink checks apply.
|
|
candidate := sandbox.NormalizePrefixForRoot(requestedPath, root)
|
|
absolute, relative, err := resolveWorkspaceTargetPath(root, candidate)
|
|
if err == nil {
|
|
if index > 0 {
|
|
// Extra-root matches report the absolute path: "relative to
|
|
// which root" is ambiguous downstream (ChangedFiles, cwd
|
|
// meta, display), and consumers document workspace-relative.
|
|
return absolute, absolute, nil
|
|
}
|
|
return absolute, relative, nil
|
|
}
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return "", "", firstErr
|
|
}
|
|
|
|
// recheckScopedWriteTarget mirrors recheckWorkspaceWriteTarget across roots.
|
|
// Prefix symlinks outside a root are resolved per root (macOS /var aliasing);
|
|
// symlinks INSIDE a root stay visible to the single-root checks, so a write
|
|
// target may resolve through a symlink only when its final location lies
|
|
// inside a DIFFERENT granted root — mirroring sandbox.Scope.validate's
|
|
// documented widening.
|
|
func recheckScopedWriteTarget(workspaceRoot string, scope PathScope, requestedPath string) error {
|
|
if requestedPath == "" || !filepath.IsAbs(requestedPath) || scope == nil {
|
|
return recheckWorkspaceWriteTarget(workspaceRoot, requestedPath)
|
|
}
|
|
roots, err := scopedRoots(workspaceRoot, scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var firstErr error
|
|
for _, root := range roots {
|
|
candidate := sandbox.NormalizePrefixForRoot(requestedPath, root)
|
|
err := recheckWorkspaceWriteTarget(root, candidate)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|