mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +08:00
## What Identify agent workspace-context **sources** by their lexical (configured) path so `coder exp chat context list` no longer shows the same directory twice, and so a source is shown as the path the operator actually configured. ## Why (the bug) Source identity was the canonical path from `CanonicalizePath`, which resolves symlinks via `EvalSymlinks` **only when the target exists**. That makes canonicalization time-dependent: - At boot the agent seeds sources from `CODER_AGENT_EXP_*_DIRS`. If `~/.coder/skills -> ~/my-agent/agent-rules/skills` and the target does not exist yet (a startup script creates it later), `~/.coder/skills` canonicalizes to the lexical `/home/coder/.coder/skills`. - After the manifest lands (or the target is added directly), the same configured source canonicalizes to the resolved `/home/coder/my-agent/agent-rules/skills`. The same configured source produced two different strings, so dedupe keyed on the string registered both and the list showed one directory twice. Resolving symlinks for identity is also misleading on its own (per @mafredri's review): a source added by a symlink path appears in the list as its resolved target, as if that target had been added explicitly. ## How Source identity is now the **lexical** path: cleaned, `~`-expanded, absolute, with symlinks **not** resolved (new `lexicalPath`; `CanonicalizePath` is refactored to build on it). `AddSource`, `SeedSources`, `HasSource`, `RemoveSource`, and boot seeding all key on this stable identity. `AddSource` still **validates** the resolved (`CanonicalizePath`) path against the allowed roots, so a symlink cannot escape them. Only the identity/display path changed. This replaces the earlier `os.SameFile`/inode dedupe, which was unstable and failed on Windows runners. ## Testing - `go test ./agent/agentcontext/` (full package) and `go vet` pass; `gofmt` clean. - `TestManager_SourceIdentityIsLexicalAndStable`: adds the same symlinked source before and after its target exists and asserts one source whose path is the lexical link (skipped on Windows, matching the package's other symlink tests). - Existing `TestCanonicalizePath_FollowsSymlinks` and `TestValidateSourcePath_*` confirm symlink resolution and the security boundary are unchanged. <details> <summary>Related review findings</summary> Fixes the "duplicate symlinked paths in `context list`" issue from the chat-context system review and the dedupe-ordering question (lexical identity preserves first-come-first-served order). Showing the configured path for **resources** (not just sources) and restoring scope-based skill precedence are separate, larger changes tracked elsewhere. </details> --- *This PR was created by Coder Agents on behalf of @kylecarbs.*
121 lines
3.2 KiB
Go
121 lines
3.2 KiB
Go
package agentcontext
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
)
|
|
|
|
// lexicalPath returns raw as a cleaned, absolute path with ~
|
|
// expanded and symlinks left unresolved.
|
|
func lexicalPath(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", xerrors.New("path is empty")
|
|
}
|
|
|
|
// ~user forms are intentionally unsupported.
|
|
if raw == "~" || strings.HasPrefix(raw, "~/") {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", xerrors.Errorf("expand home dir: %w", err)
|
|
}
|
|
if raw == "~" {
|
|
raw = home
|
|
} else {
|
|
raw = filepath.Join(home, raw[2:])
|
|
}
|
|
}
|
|
|
|
if !filepath.IsAbs(raw) {
|
|
// Relative paths are ambiguous; require an absolute path.
|
|
return "", xerrors.Errorf("path %q is not absolute", raw)
|
|
}
|
|
|
|
return filepath.Clean(raw), nil
|
|
}
|
|
|
|
// CanonicalizePath returns lexicalPath with symlinks resolved
|
|
// when the target exists.
|
|
func CanonicalizePath(raw string) (string, error) {
|
|
cleaned, err := lexicalPath(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(cleaned); err == nil {
|
|
return resolved, nil
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
// ValidateSourcePath enforces the path-validation rules from
|
|
// the RFC's Authorization section. It rejects:
|
|
//
|
|
// - Paths containing ".." segments after expansion.
|
|
// - Paths resolving outside the supplied allowedRoots, unless
|
|
// allowedRoots is empty (which disables the check).
|
|
//
|
|
// allowedRoots are canonicalized lazily; missing roots are
|
|
// silently skipped so a workspace with no $HOME does not break
|
|
// validation for project-relative roots.
|
|
func ValidateSourcePath(canonical string, allowedRoots []string) error {
|
|
if canonical == "" {
|
|
return xerrors.New("path is empty")
|
|
}
|
|
// filepath.Clean drops "." but leaves ".." when no parent
|
|
// is available. Reject defensively.
|
|
for _, part := range strings.Split(canonical, string(os.PathSeparator)) {
|
|
if part == ".." {
|
|
return xerrors.Errorf("path %q contains parent traversal segments", canonical)
|
|
}
|
|
}
|
|
|
|
if len(allowedRoots) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Build canonical, deduplicated allowed roots. Missing
|
|
// roots (e.g. an unconfigured ~/.claude/) are skipped.
|
|
roots := make([]string, 0, len(allowedRoots))
|
|
seen := make(map[string]struct{}, len(allowedRoots))
|
|
for _, raw := range allowedRoots {
|
|
c, err := CanonicalizePath(raw)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, ok := seen[c]; ok {
|
|
continue
|
|
}
|
|
seen[c] = struct{}{}
|
|
roots = append(roots, c)
|
|
}
|
|
if len(roots) == 0 {
|
|
// All configured roots were invalid; treat as "deny
|
|
// everything" so misconfiguration fails closed.
|
|
return xerrors.Errorf("path %q is not inside any allowed root", canonical)
|
|
}
|
|
|
|
for _, root := range roots {
|
|
if pathHasPrefix(canonical, root) {
|
|
return nil
|
|
}
|
|
}
|
|
return xerrors.Errorf("path %q is not inside any allowed root", canonical)
|
|
}
|
|
|
|
// pathHasPrefix reports whether path is equal to or a
|
|
// descendant of prefix. Both arguments must already be clean,
|
|
// absolute paths.
|
|
func pathHasPrefix(path, prefix string) bool {
|
|
if path == prefix {
|
|
return true
|
|
}
|
|
withSep := prefix
|
|
if !strings.HasSuffix(withSep, string(os.PathSeparator)) {
|
|
withSep += string(os.PathSeparator)
|
|
}
|
|
return strings.HasPrefix(path, withSep)
|
|
}
|