diff --git a/core-skills/agentdock-user-guide/references/configuration.md b/core-skills/agentdock-user-guide/references/configuration.md index 20e0388b..862ca6ac 100644 --- a/core-skills/agentdock-user-guide/references/configuration.md +++ b/core-skills/agentdock-user-guide/references/configuration.md @@ -32,10 +32,11 @@ | `AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL` | OAuth Access Token 有效期 | Desktop/高级启动配置 | | `AGENTDOCK_STDIO` | 是否启用 stdio 运行模式 | 直接启动/集成场景 | | `AGENTDOCK_TRUSTED_PROXY_CIDRS` | 受信任反向代理网段 | 服务器/反代场景 | -| `AGENTDOCK_INSTRUCTIONS_FILE` | 额外 Instructions 文件 | 高级启动配置 | Coding Agent 的发现、Codex / Claude Adapter 安装、Grok stdio 模式、平台配置和验证流程见 `acp.md`。 +项目规则不通过环境变量配置:全局规则固定为 `~/.agentdock/AGENTS.md`,工作区规则与 `.agents/skills` 索引通过 `workspace_context` 按请求读取。 + ## 重要边界 - Windows Desktop 不应把认证秘密直接写入 `control-panel-settings.json`。Bearer Token、OAuth 密码、OAuth 签名密钥和 Tunnel Token 使用平台受保护存储。 diff --git a/docs/agents-context.md b/docs/agents-context.md new file mode 100644 index 00000000..3f5f0fda --- /dev/null +++ b/docs/agents-context.md @@ -0,0 +1,42 @@ +# Workspace 上下文 + +AgentDock 把设备级能力总览与项目级规则分开处理:`agentdock_context` 只返回 Runtime、Skill、动态 MCP、Workflow、Recall、ACP 和稳定操作规则;项目规则与工作区 Skill 由独立的 `workspace_context` 按请求读取。 + +## 规则文件 + +AgentDock 只识别两类 `AGENTS.md`: + +1. 全局规则固定为 `~/.agentdock/AGENTS.md`;不提供自定义全局规则路径。 +2. 工作区规则从 workspace root 的 `AGENTS.md` 开始,到本次 `workdir` 之间逐级继承子目录 `AGENTS.md`。 + +workspace root 优先使用离 `workdir` 最近的 Git / worktree 边界;在 AgentDock 默认工作目录内部且没有更近 Git 边界时,以默认工作目录为边界;两者都不存在时只检查当前 `workdir`。 + +`workspace_context` 每次调用都重新读取文件,不使用 mtime 缓存,也不会修改进程 cwd、AgentDock 默认工作目录或后续命令的默认目录。开始操作具体项目、切换工作区或规则可能变化时应重新调用。 + +## workspace_context + +直接连接 AgentDock 时输入只有一个可选字段: + +```json +{"workdir": "/absolute/or/host-resolvable/workspace"} +``` + +省略 `workdir` 使用 AgentDock 当前默认工作目录。返回结构包含: + +- `workdir`:本次实际解析的工作目录; +- `workspace_root`:规则继承和 workspace Skill 扫描的根目录; +- `instructions`:按全局 → workspace root → 子目录顺序排列的规则文件状态与完整正文; +- `workspace_skills`:`/.agents/skills/*/SKILL.md` 的 name / description / file 索引,不返回 Skill 正文; +- `warnings`:无法生成某个 best-effort 索引时的安全提示。 + +`instructions` 中每项包含 `scope`、`path`、`status`,并按状态提供 `content`、`sha256`、`size_bytes`、`reason` 或 `duplicate_of`。状态可能是 `loaded`、`not_found`、`empty`、`duplicate`、`skipped`、`error`。只有 `loaded` 的正文是完整有效规则;AgentDock 不会截断正文后伪装成成功加载。 + +## 安全与预算 + +每个 `AGENTS.md` 最多 64 KiB,单次 `workspace_context` 的规则正文总预算为 128 KiB。超过总预算的后续文件保留 metadata 并标记 `skipped`。读取要求 UTF-8 文本且拒绝 NUL、leaf symlink、FIFO 和其他非普通文件;使用 `os.Root`、文件身份校验和物理文件去重限制竞态与路径逃逸。 + +Workspace Skill 固定扫描 `/.agents/skills//SKILL.md`,只建立 metadata 索引。需要执行 Skill 时再用 `read_file` 读取返回的 `file`。选择同名能力时优先级为 workspace Skill → AgentDock Skill → `~/.agents/skills` common Skill。 + +## MCP 初始化 + +MCP 初始化 instructions 只包含稳定的 AgentDock 使用说明,不注入任何全局或工作区 `AGENTS.md` 正文。这样同一 Core 切换项目时不会在初始化上下文里残留旧工作区规则。 diff --git a/go.mod b/go.mod index 63bbf776..6f1e1551 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/rogpeppe/go-internal v1.15.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - github.com/uvwt/agentdock-protocol v0.8.1 + github.com/uvwt/agentdock-protocol v0.8.2-0.20260922024821-59aad94f04bb golang.org/x/sys v0.45.0 ) diff --git a/go.sum b/go.sum index 64c45819..a052cc3e 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,8 @@ github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2K github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao= github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE= github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ= -github.com/uvwt/agentdock-protocol v0.8.1 h1:DweChXqBJk8EkWZYenGasyPDcDXL0w2lvXM9lyPYlL0= -github.com/uvwt/agentdock-protocol v0.8.1/go.mod h1:yoFrGa/mNuAr3b8fupHCFT0b1Kf4Ni/00T4KnwuiFRk= +github.com/uvwt/agentdock-protocol v0.8.2-0.20260922024821-59aad94f04bb h1:M55sK4PytzsvYRTgzOZyMkHMCSeo3WH0VnJk3mneGBo= +github.com/uvwt/agentdock-protocol v0.8.2-0.20260922024821-59aad94f04bb/go.mod h1:yoFrGa/mNuAr3b8fupHCFT0b1Kf4Ni/00T4KnwuiFRk= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= diff --git a/internal/agentinstructions/instructions.go b/internal/agentinstructions/instructions.go new file mode 100644 index 00000000..a57faa5e --- /dev/null +++ b/internal/agentinstructions/instructions.go @@ -0,0 +1,238 @@ +// Package agentinstructions discovers bounded, workspace-scoped AGENTS.md guidance. +// It has no mutable workspace state and never executes instructions or file contents. +package agentinstructions + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + "unicode/utf8" +) + +const ( + Filename = "AGENTS.md" + MaxFileBytes = 64 << 10 + MaxTotalBytes = 128 << 10 + MaxDirectories = 64 +) + +type Options struct { + Home string + DefaultDir string + Workdir string +} + +type File struct { + Scope string `json:"scope"` + Path string `json:"path"` + Status string `json:"status"` + Content string `json:"content,omitempty"` + SHA256 string `json:"sha256,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + Reason string `json:"reason,omitempty"` + DuplicateOf string `json:"duplicate_of,omitempty"` +} + +type Snapshot struct { + Workdir string `json:"workdir"` + WorkspaceRoot string `json:"workspace_root"` + Files []File `json:"files"` +} + +type candidate struct { + scope, path, root string +} + +type loadedFile struct { + info os.FileInfo + path string +} + +// Load reads fresh content on each request. Missing optional files are normal; +// unreadable or invalid files are reported without including partial instructions. +func Load(ctx context.Context, options Options) (Snapshot, error) { + snapshot := Snapshot{Workdir: options.Workdir, WorkspaceRoot: options.Workdir, Files: []File{}} + if err := ctx.Err(); err != nil { + return snapshot, err + } + if !filepath.IsAbs(options.Workdir) { + return snapshot, errors.New("instruction workdir must be an absolute directory") + } + info, err := os.Stat(options.Workdir) + if err != nil || !info.IsDir() { + return snapshot, errors.New("instruction workdir must be an existing directory") + } + candidates := []candidate{} + if options.Home != "" { + if !filepath.IsAbs(options.Home) { + return snapshot, errors.New("instruction home must be absolute") + } + candidates = append(candidates, candidate{scope: "global", path: filepath.Join(options.Home, Filename), root: options.Home}) + } + dirs, err := workspaceDirectories(ctx, options.Workdir, options.DefaultDir) + if err != nil { + return snapshot, err + } + snapshot.WorkspaceRoot = dirs[0] + for _, dir := range dirs { + candidates = append(candidates, candidate{scope: "workspace", path: filepath.Join(dir, Filename), root: dirs[0]}) + } + seen := []loadedFile{} + remaining := int64(MaxTotalBytes) + for _, source := range candidates { + if err := ctx.Err(); err != nil { + return snapshot, err + } + file, info := readCandidate(source) + if file.Status == "loaded" { + for _, prior := range seen { + if os.SameFile(prior.info, info) { + file.Status, file.Content, file.DuplicateOf = "duplicate", "", prior.path + break + } + } + if file.Status == "loaded" { + if file.SizeBytes > remaining { + file.Status, file.Content, file.Reason = "skipped", "", "total_size_limit" + } else { + remaining -= file.SizeBytes + seen = append(seen, loadedFile{info: info, path: file.Path}) + } + } + } + snapshot.Files = append(snapshot.Files, file) + } + return snapshot, ctx.Err() +} + +// Only repository ancestors (or ancestors inside the configured default directory) +// are eligible. We never read parent AGENTS.md files outside this boundary. +func workspaceDirectories(ctx context.Context, workdir, defaultDir string) ([]string, error) { + boundary := "" + if filepath.IsAbs(defaultDir) && within(defaultDir, workdir) { + boundary = filepath.Clean(defaultDir) + } + root := workdir + found := false + for dir, count := workdir, 0; ; dir, count = filepath.Dir(dir), count+1 { + if err := ctx.Err(); err != nil { + return nil, err + } + if count >= MaxDirectories { + return nil, errors.New("workspace instruction discovery exceeds directory limit") + } + if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil { + root, found = dir, true + break + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("cannot inspect workspace boundary: %w", err) + } + if boundary != "" { + // filepath.Rel applies the host's path equality rules, including + // case-insensitive drive and directory names on Windows. + if rel, err := filepath.Rel(boundary, dir); err == nil && rel == "." { + root, found = dir, true + break + } + } + if filepath.Dir(dir) == dir { + break + } + } + if !found { + return []string{workdir}, nil + } + dirs := []string{} + for dir := workdir; ; dir = filepath.Dir(dir) { + if len(dirs) >= MaxDirectories { + return nil, errors.New("workspace instruction inheritance exceeds directory limit") + } + dirs = append(dirs, dir) + if dir == root { + break + } + } + slices.Reverse(dirs) + return dirs, nil +} + +func within(root, path string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +func readCandidate(source candidate) (File, os.FileInfo) { + file := File{Scope: source.scope, Path: filepath.Clean(source.path)} + // AGENTS.md discovery never follows a leaf symlink into another file. + root, err := os.OpenRoot(source.root) + if err != nil { + return failedFile(file, err), nil + } + defer root.Close() + rel, err := filepath.Rel(source.root, source.path) + if err != nil || !within(source.root, source.path) { + file.Status, file.Reason = "skipped", "outside_scope" + return file, nil + } + before, err := root.Lstat(rel) + if err != nil { + return failedFile(file, err), nil + } + if !before.Mode().IsRegular() { + file.Status, file.Reason = "skipped", "not_regular_file" + return file, nil + } + if before.Size() > MaxFileBytes { + file.Status, file.Reason, file.SizeBytes = "skipped", "file_size_limit", before.Size() + return file, nil + } + opened, err := root.OpenFile(rel, instructionOpenFlags(), 0) + if err != nil { + return failedFile(file, err), nil + } + defer opened.Close() + after, err := opened.Stat() + if err != nil { + return failedFile(file, err), nil + } + if !after.Mode().IsRegular() || !os.SameFile(before, after) { + file.Status, file.Reason = "skipped", "file_changed_during_read" + return file, nil + } + data, err := io.ReadAll(io.LimitReader(opened, MaxFileBytes+1)) + if err != nil { + return failedFile(file, err), nil + } + file.SizeBytes = int64(len(data)) + if len(data) > MaxFileBytes { + file.Status, file.Reason = "skipped", "file_size_limit" + return file, nil + } + if !utf8.Valid(data) || strings.ContainsRune(string(data), 0) { + file.Status, file.Reason = "skipped", "invalid_utf8_text" + return file, nil + } + file.Content = strings.TrimSpace(strings.TrimPrefix(string(data), "\ufeff")) + if file.Content == "" { + file.Status = "empty" + return file, nil + } + file.Status, file.SHA256 = "loaded", fmt.Sprintf("%x", sha256.Sum256(data)) + return file, after +} + +func failedFile(file File, err error) File { + file.Status, file.Reason = "error", "read_failed" + if errors.Is(err, os.ErrNotExist) { + file.Status, file.Reason = "not_found", "" + } else if errors.Is(err, os.ErrPermission) { + file.Reason = "permission_denied" + } + return file +} diff --git a/internal/agentinstructions/instructions_test.go b/internal/agentinstructions/instructions_test.go new file mode 100644 index 00000000..4658efb8 --- /dev/null +++ b/internal/agentinstructions/instructions_test.go @@ -0,0 +1,251 @@ +package agentinstructions + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeGuidance(t *testing.T, dir, text string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, Filename) + if err := os.WriteFile(path, []byte(text), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func loadGuidance(t *testing.T, options Options) Snapshot { + t.Helper() + snapshot, err := Load(t.Context(), options) + if err != nil { + t.Fatal(err) + } + return snapshot +} + +func loadedContents(snapshot Snapshot) []string { + var values []string + for _, file := range snapshot.Files { + if file.Status == "loaded" { + values = append(values, file.Content) + } + } + return values +} + +func TestLoadGlobalRootAndNestedInOrder(t *testing.T) { + home, root := t.TempDir(), t.TempDir() + child := filepath.Join(root, "项目 with spaces", "src") + writeGuidance(t, home, "\ufeff# 全局\r\n不要自动执行 ACP。\r\n") + writeGuidance(t, root, "# 项目规则") + writeGuidance(t, filepath.Dir(child), "# 子目录规则") + writeGuidance(t, child, "# 当前目录规则") + options := Options{Home: home, DefaultDir: root, Workdir: child} + snapshot := loadGuidance(t, options) + got := strings.Join(loadedContents(snapshot), "|") + want := "# 全局\r\n不要自动执行 ACP。|# 项目规则|# 子目录规则|# 当前目录规则" + if got != want { + t.Fatalf("ordered contents = %q, want %q", got, want) + } + if snapshot.Workdir != child || snapshot.WorkspaceRoot != root { + t.Fatalf("snapshot = %#v", snapshot) + } + for _, file := range snapshot.Files { + if len(file.SHA256) != 64 || file.SizeBytes == 0 { + t.Fatalf("missing provenance: %#v", file) + } + } +} + +func TestLoadRefreshesEvenWhenSizeAndModificationTimeAreUnchanged(t *testing.T) { + home, root := t.TempDir(), t.TempDir() + path := writeGuidance(t, root, "old rule") + options := Options{Home: home, DefaultDir: root, Workdir: root} + before := loadGuidance(t, options) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + writeGuidance(t, root, "new rule") + if err := os.Chtimes(path, info.ModTime(), info.ModTime()); err != nil { + t.Fatal(err) + } + after := loadGuidance(t, options) + if got := strings.Join(loadedContents(after), ""); got != "new rule" { + t.Fatalf("stale guidance: %s", got) + } + if before.Files[1].SHA256 == after.Files[1].SHA256 { + t.Fatal("digest did not change") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + removed := loadGuidance(t, options) + if removed.Files[1].Status != "not_found" || len(loadedContents(removed)) != 0 { + t.Fatalf("deleted file was cached: %#v", removed) + } + writeGuidance(t, root, "created again") + if got := strings.Join(loadedContents(loadGuidance(t, options)), ""); got != "created again" { + t.Fatalf("new file not detected: %s", got) + } +} + +func TestRepositoryBoundaryDoesNotReadUnrelatedAncestorsOrSiblings(t *testing.T) { + outer := t.TempDir() + root, child := filepath.Join(outer, "repo"), filepath.Join(outer, "repo", "src") + writeGuidance(t, outer, "OUTSIDE") + writeGuidance(t, root, "ROOT") + writeGuidance(t, child, "CHILD") + writeGuidance(t, filepath.Join(root, "sibling"), "SIBLING") + // Worktrees use a .git file instead of a directory; no Git command is needed. + if err := os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: ignored"), 0o600); err != nil { + t.Fatal(err) + } + snapshot := loadGuidance(t, Options{Home: t.TempDir(), DefaultDir: t.TempDir(), Workdir: child}) + if got := strings.Join(loadedContents(snapshot), "|"); got != "ROOT|CHILD" { + t.Fatalf("boundary leak: %s", got) + } + if snapshot.WorkspaceRoot != root { + t.Fatalf("root = %q", snapshot.WorkspaceRoot) + } + if err := os.Remove(filepath.Join(root, ".git")); err != nil { + t.Fatal(err) + } + snapshot = loadGuidance(t, Options{Home: t.TempDir(), DefaultDir: t.TempDir(), Workdir: child}) + if got := strings.Join(loadedContents(snapshot), "|"); got != "CHILD" { + t.Fatalf("nonrepository read parent rules: %s", got) + } +} + +func TestNestedRepositoryStopsAtNearestBoundary(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "nested") + writeGuidance(t, root, "outer") + writeGuidance(t, nested, "nested") + if err := os.Mkdir(filepath.Join(root, ".git"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(nested, ".git"), 0o700); err != nil { + t.Fatal(err) + } + snapshot := loadGuidance(t, Options{Home: t.TempDir(), DefaultDir: root, Workdir: nested}) + if got := strings.Join(loadedContents(snapshot), "|"); got != "nested" { + t.Fatalf("nested boundary = %s", got) + } +} + +func TestFileValidationNeverReturnsPartialGuidance(t *testing.T) { + for _, test := range []struct{ name, content, status, reason string }{ + {"empty", " \r\n\t", "empty", ""}, + {"bom_only", "\ufeff", "empty", ""}, + {"invalid_utf8", string([]byte{0xff, 0xfe}), "skipped", "invalid_utf8_text"}, + {"nul", "text\x00text", "skipped", "invalid_utf8_text"}, + {"at_limit", strings.Repeat("x", MaxFileBytes), "loaded", ""}, + {"over_limit", strings.Repeat("x", MaxFileBytes+1), "skipped", "file_size_limit"}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + writeGuidance(t, root, test.content) + snapshot := loadGuidance(t, Options{DefaultDir: root, Workdir: root}) + file := snapshot.Files[0] + if file.Status != test.status || file.Reason != test.reason { + t.Fatalf("file = %#v", file) + } + if file.Status != "loaded" && file.Content != "" { + t.Fatal("returned partial/invalid content") + } + }) + } +} + +func TestNonRegularFileIsSkipped(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, Filename), 0o700); err != nil { + t.Fatal(err) + } + snapshot := loadGuidance(t, Options{DefaultDir: root, Workdir: root}) + if snapshot.Files[0].Reason != "not_regular_file" { + t.Fatalf("file = %#v", snapshot.Files[0]) + } +} + +func TestAutomaticSymlinkIsNotFollowed(t *testing.T) { + home, root, outside := t.TempDir(), t.TempDir(), t.TempDir() + target := writeGuidance(t, outside, "outside guidance") + link := filepath.Join(root, Filename) + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + snapshot := loadGuidance(t, Options{Home: home, DefaultDir: root, Workdir: root}) + if len(loadedContents(snapshot)) != 0 || snapshot.Files[1].Reason != "not_regular_file" { + t.Fatalf("followed automatic symlink: %#v", snapshot) + } +} + +func TestHardLinkIsDeduplicated(t *testing.T) { + home, root := t.TempDir(), t.TempDir() + path := writeGuidance(t, home, "one physical file") + if err := os.Link(path, filepath.Join(root, Filename)); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + snapshot := loadGuidance(t, Options{Home: home, DefaultDir: root, Workdir: root}) + if snapshot.Files[1].Status != "duplicate" || len(loadedContents(snapshot)) != 1 { + t.Fatalf("hardlink repeated: %#v", snapshot) + } +} + +func TestTotalBudgetSkipsWholeFiles(t *testing.T) { + root := t.TempDir() + dir := root + for range 5 { + writeGuidance(t, dir, strings.Repeat("x", MaxFileBytes)) + dir = filepath.Join(dir, "child") + } + workdir := filepath.Dir(dir) + snapshot := loadGuidance(t, Options{DefaultDir: root, Workdir: workdir}) + if len(loadedContents(snapshot)) != MaxTotalBytes/MaxFileBytes || snapshot.Files[4].Reason != "total_size_limit" || snapshot.Files[4].Content != "" { + t.Fatalf("budget not enforced: statuses=%v", func() []string { + var s []string + for _, f := range snapshot.Files { + s = append(s, f.Status+":"+f.Reason) + } + return s + }()) + } +} + +func TestLoadRejectsInvalidSelectionAndHonorsCancellation(t *testing.T) { + root := t.TempDir() + file := writeGuidance(t, root, "rules") + for _, workdir := range []string{"relative", file, filepath.Join(root, "missing")} { + if _, err := Load(t.Context(), Options{Workdir: workdir}); err == nil { + t.Fatalf("accepted workdir %q", workdir) + } + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := Load(ctx, Options{DefaultDir: root, Workdir: root}); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation = %v", err) + } +} + +func TestWorkspaceDepthIsBounded(t *testing.T) { + root := t.TempDir() + dir := root + for range MaxDirectories { + dir = filepath.Join(dir, "a") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if _, err := Load(t.Context(), Options{DefaultDir: root, Workdir: dir}); err == nil { + t.Fatal("unbounded directory traversal") + } +} diff --git a/internal/agentinstructions/instructions_unix_test.go b/internal/agentinstructions/instructions_unix_test.go new file mode 100644 index 00000000..f21048e6 --- /dev/null +++ b/internal/agentinstructions/instructions_unix_test.go @@ -0,0 +1,37 @@ +//go:build unix + +package agentinstructions + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestInstructionFIFOIsRejectedWithoutOpening(t *testing.T) { + root := t.TempDir() + if err := syscall.Mkfifo(filepath.Join(root, Filename), 0o600); err != nil { + t.Fatal(err) + } + snapshot := loadGuidance(t, Options{DefaultDir: root, Workdir: root}) + if snapshot.Files[0].Reason != "not_regular_file" || snapshot.Files[0].Content != "" { + t.Fatalf("FIFO accepted: %#v", snapshot.Files[0]) + } +} + +func TestInstructionPermissionDeniedReturnsNoBody(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can read files without DAC read permission") + } + root := t.TempDir() + path := writeGuidance(t, root, "unreadable marker") + if err := os.Chmod(path, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0o600) }) + snapshot := loadGuidance(t, Options{DefaultDir: root, Workdir: root}) + if snapshot.Files[0].Status != "error" || snapshot.Files[0].Reason != "permission_denied" || snapshot.Files[0].Content != "" { + t.Fatalf("unreadable file accepted: %#v", snapshot.Files[0]) + } +} diff --git a/internal/agentinstructions/instructions_windows_test.go b/internal/agentinstructions/instructions_windows_test.go new file mode 100644 index 00000000..903fb436 --- /dev/null +++ b/internal/agentinstructions/instructions_windows_test.go @@ -0,0 +1,18 @@ +package agentinstructions + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestWindowsCaseInsensitiveDefaultBoundary(t *testing.T) { + root := filepath.Join(t.TempDir(), "MixedCaseProject") + child := filepath.Join(root, "src") + writeGuidance(t, root, "root rules") + writeGuidance(t, child, "child rules") + snapshot := loadGuidance(t, Options{DefaultDir: strings.ToUpper(root), Workdir: child}) + if got := strings.Join(loadedContents(snapshot), "|"); got != "root rules|child rules" { + t.Fatalf("case-variant default directory lost ancestor instructions: %q", got) + } +} diff --git a/internal/agentinstructions/open_other.go b/internal/agentinstructions/open_other.go new file mode 100644 index 00000000..bb4f39cf --- /dev/null +++ b/internal/agentinstructions/open_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package agentinstructions + +import "os" + +func instructionOpenFlags() int { return os.O_RDONLY } diff --git a/internal/agentinstructions/open_unix.go b/internal/agentinstructions/open_unix.go new file mode 100644 index 00000000..99eebebe --- /dev/null +++ b/internal/agentinstructions/open_unix.go @@ -0,0 +1,14 @@ +//go:build unix + +package agentinstructions + +import ( + "os" + "syscall" +) + +func instructionOpenFlags() int { + // A regular file can be replaced between Lstat and OpenFile. Do not block + // on a substituted FIFO or follow a newly substituted leaf symlink. + return os.O_RDONLY | syscall.O_NONBLOCK | syscall.O_NOFOLLOW +} diff --git a/internal/app/agentdock_context.go b/internal/app/agentdock_context.go index 46928a40..6ae3d142 100644 --- a/internal/app/agentdock_context.go +++ b/internal/app/agentdock_context.go @@ -33,8 +33,9 @@ func (r *Runtime) agentDockContext(ctx context.Context, nexusLocalOnly bool) (Re Rules: []string{ "需要真实执行命令或检查环境时,先用 exec_command 查看现状,再修改,修改后真实验证。", "先根据 Skill 索引的 name 和 description 选择相关 Skill,再用 read_file 读取其 file 指向的 SKILL.md;Skill 只提供流程与约束,实际操作使用命令、文件、浏览器或 MCP 工具。", - "选择 Skill 时优先使用 skills 中的 AgentDock Skill;common_skills 是低优先级通用 Skill 索引,同名时始终优先 skills。若 common_skills.truncated=true 且当前索引未命中,可直接 list_dir 查看 common_skills.root,再用 read_file 读取对应 SKILL.md。", + "选择 Skill 时,已调用 workspace_context 的当前项目优先使用其 workspace_skills;同名优先级为 workspace Skill > skills 中的 AgentDock Skill > common_skills 中的全局通用 Skill。若 common_skills.truncated=true 且当前索引未命中,可直接 list_dir 查看 common_skills.root,再用 read_file 读取对应 SKILL.md。", "AgentDock 自带工具直接调用;动态 MCP 工具先用 mcp_tool_search 查找、mcp_tool_inspect 读取 schema,再用 mcp_tool_call 执行。", + "操作具体项目、切换工作区或工作区规则可能变化时,先调用 workspace_context 获取当前工作区上下文。", }, } if !nexusLocalOnly { @@ -99,8 +100,14 @@ func (r *Runtime) agentDockContext(ctx context.Context, nexusLocalOnly bool) (Re return result, nil } -func (r *Runtime) agentDockContextTool(ctx context.Context, _ map[string]any) (Result, error) { - return r.AgentDockContext(ctx) +type agentDockContextRequest struct{} + +func (r *Runtime) agentDockContextTool(ctx context.Context, args map[string]any) (Result, error) { + var request agentDockContextRequest + if err := decodeToolInput("agentdock_context", args, &request); err != nil { + return nil, err + } + return r.agentDockContext(ctx, false) } type capabilityContext struct { diff --git a/internal/app/agentdock_context_test.go b/internal/app/agentdock_context_test.go index 5161f642..bf343857 100644 --- a/internal/app/agentdock_context_test.go +++ b/internal/app/agentdock_context_test.go @@ -71,7 +71,7 @@ func TestAgentDockContextToolReturnsStructuredRuntimeIndex(t *testing.T) { t.Fatalf("runtime paths = %#v", got.Runtime) } rules := strings.Join(got.Rules, "\n") - for _, want := range []string{"AgentDock 自带工具直接调用", "同名时始终优先 skills", "common_skills.truncated=true", "task_manage checkpoint"} { + for _, want := range []string{"AgentDock 自带工具直接调用", "同名优先级为 workspace Skill > skills", "common_skills.truncated=true", "task_manage checkpoint"} { if !strings.Contains(rules, want) { t.Fatalf("context rules missing %q: %s", want, rules) } diff --git a/internal/app/common_skills.go b/internal/app/common_skills.go index a4c33912..33cec9e8 100644 --- a/internal/app/common_skills.go +++ b/internal/app/common_skills.go @@ -3,15 +3,6 @@ package app import ( "os" "path/filepath" - "sort" - "strings" - - skills "github.com/uvwt/agentdock/internal/skill" -) - -const ( - commonSkillIndexLimit = 50 - commonSkillDescriptionBytes = 120 ) func commonSkillCapabilityIndex() (*capabilityCommonSkillIndex, error) { @@ -20,48 +11,15 @@ func commonSkillCapabilityIndex() (*capabilityCommonSkillIndex, error) { return nil, err } root := filepath.Join(home, ".agents", "skills") - entries, err := os.ReadDir(root) + index, err := scanFilesystemSkills(root, filesystemSkillScanOptions{AllowPackageSymlinks: true}) if err != nil { - if os.IsNotExist(err) { - return &capabilityCommonSkillIndex{Root: root, Items: []capabilityCommonSkillItem{}}, nil - } return nil, err } - - items := make([]capabilityCommonSkillItem, 0, len(entries)) - for _, entry := range entries { - packageDir := filepath.Join(root, entry.Name()) - info, statErr := os.Stat(packageDir) - if statErr != nil || !info.IsDir() { - continue - } - documentPath := filepath.Join(packageDir, "SKILL.md") - data, readErr := os.ReadFile(documentPath) - if readErr != nil { - continue - } - metadata, parseErr := skills.ParseSkillMetadata(data) - if parseErr != nil { - continue - } + items := make([]capabilityCommonSkillItem, 0, len(index.Items)) + for _, item := range index.Items { items = append(items, capabilityCommonSkillItem{ - Name: metadata.Name, - Description: truncateString(strings.TrimSpace(metadata.Description), commonSkillDescriptionBytes), - File: documentPath, + Name: item.Name, Description: item.Description, File: item.File, }) } - - // 文件系统遍历顺序不应影响启动 Context;按名称和路径稳定排序后再截断。 - sort.Slice(items, func(i, j int) bool { - if items[i].Name == items[j].Name { - return items[i].File < items[j].File - } - return items[i].Name < items[j].Name - }) - total := len(items) - truncated := total > commonSkillIndexLimit - if truncated { - items = items[:commonSkillIndexLimit] - } - return &capabilityCommonSkillIndex{Root: root, Total: total, Truncated: truncated, Items: items}, nil + return &capabilityCommonSkillIndex{Root: root, Total: index.Total, Truncated: index.Truncated, Items: items}, nil } diff --git a/internal/app/common_skills_test.go b/internal/app/common_skills_test.go index 02fefcb4..a1a755c9 100644 --- a/internal/app/common_skills_test.go +++ b/internal/app/common_skills_test.go @@ -21,7 +21,7 @@ func TestCommonSkillCapabilityIndexListsValidSkillsInStableOrder(t *testing.T) { setUserHomeForTest(t, home) root := filepath.Join(home, ".agents", "skills") writeCommonSkillForTest(t, root, "z-dir", "z-skill", "Z skill description.") - writeCommonSkillForTest(t, root, "a-dir", "a-skill", strings.Repeat("A", commonSkillDescriptionBytes+40)) + writeCommonSkillForTest(t, root, "a-dir", "a-skill", strings.Repeat("A", filesystemSkillDescriptionBytes+40)) writeCommonSkillFileForTest(t, filepath.Join(root, "invalid", "SKILL.md"), "---\nname: invalid\ndescription:\n---\n\n# Invalid\n") index, err := commonSkillCapabilityIndex() @@ -37,7 +37,7 @@ func TestCommonSkillCapabilityIndexListsValidSkillsInStableOrder(t *testing.T) { if index.Items[0].File != filepath.Join(root, "a-dir", "SKILL.md") { t.Fatalf("common Skill file path = %q", index.Items[0].File) } - if len(index.Items[0].Description) > commonSkillDescriptionBytes { + if len(index.Items[0].Description) > filesystemSkillDescriptionBytes { t.Fatalf("description was not truncated: %q", index.Items[0].Description) } } @@ -46,7 +46,7 @@ func TestCommonSkillCapabilityIndexTruncatesWithoutDroppingTotal(t *testing.T) { home := t.TempDir() setUserHomeForTest(t, home) root := filepath.Join(home, ".agents", "skills") - for index := 0; index < commonSkillIndexLimit+3; index++ { + for index := 0; index < filesystemSkillIndexLimit+3; index++ { name := fmt.Sprintf("skill-%02d", index) writeCommonSkillForTest(t, root, name, name, "Common skill.") } @@ -55,7 +55,7 @@ func TestCommonSkillCapabilityIndexTruncatesWithoutDroppingTotal(t *testing.T) { if err != nil { t.Fatal(err) } - if got.Total != commonSkillIndexLimit+3 || !got.Truncated || len(got.Items) != commonSkillIndexLimit { + if got.Total != filesystemSkillIndexLimit+3 || !got.Truncated || len(got.Items) != filesystemSkillIndexLimit { t.Fatalf("unexpected truncated index: %#v", got) } if got.Items[0].Name != "skill-00" || got.Items[len(got.Items)-1].Name != "skill-49" { @@ -91,3 +91,42 @@ func writeCommonSkillFileForTest(t *testing.T, path, content string) { t.Fatal(err) } } + +func TestCommonSkillCapabilityIndexKeepsPackageDirectorySymlink(t *testing.T) { + home := t.TempDir() + setUserHomeForTest(t, home) + root := filepath.Join(home, ".agents", "skills") + targetRoot := t.TempDir() + writeCommonSkillForTest(t, targetRoot, "linked-skill", "linked-skill", "Linked common skill.") + target := filepath.Join(targetRoot, "linked-skill") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(root, "linked")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + got, err := commonSkillCapabilityIndex() + if err != nil { + t.Fatal(err) + } + if len(got.Items) != 1 || got.Items[0].Name != "linked-skill" { + t.Fatalf("common Skill package symlink disappeared: %#v", got.Items) + } +} + +func TestCommonSkillCapabilityIndexKeepsLargeExistingSkillMetadata(t *testing.T) { + home := t.TempDir() + setUserHomeForTest(t, home) + root := filepath.Join(home, ".agents", "skills") + content := "---\nname: large-skill\ndescription: Large common skill.\n---\n\n# Large\n\n" + strings.Repeat("x", 70<<10) + writeCommonSkillFileForTest(t, filepath.Join(root, "large-skill", "SKILL.md"), content) + + got, err := commonSkillCapabilityIndex() + if err != nil { + t.Fatal(err) + } + if len(got.Items) != 1 || got.Items[0].Name != "large-skill" { + t.Fatalf("common Skill larger than AGENTS.md budget disappeared: %#v", got.Items) + } +} diff --git a/internal/app/contract_drift_test.go b/internal/app/contract_drift_test.go index 7e84a908..3e2c0fe8 100644 --- a/internal/app/contract_drift_test.go +++ b/internal/app/contract_drift_test.go @@ -69,6 +69,8 @@ func TestTypedToolRequestFieldsMatchPublishedSchemas(t *testing.T) { exact bool allowExtra []string }{ + {name: "agentdock_context", request: agentDockContextRequest{}, exact: true}, + {name: "workspace_context", request: workspaceContextRequest{}, exact: true}, {name: toolfile.ToolReadFile, request: toolfile.ReadRequest{}, exact: true, allowExtra: []string{"runtime", "wsl_distribution"}}, {name: toolfile.ToolListDir, request: toolfile.ListRequest{}, exact: true, allowExtra: []string{"runtime", "wsl_distribution"}}, {name: toolfile.ToolSearchText, request: toolfile.SearchRequest{}, exact: true, allowExtra: []string{"runtime", "wsl_distribution"}}, diff --git a/internal/app/filesystem_skills.go b/internal/app/filesystem_skills.go new file mode 100644 index 00000000..3e27d745 --- /dev/null +++ b/internal/app/filesystem_skills.go @@ -0,0 +1,137 @@ +package app + +import ( + "io" + "os" + "path/filepath" + "sort" + "strings" + + skills "github.com/uvwt/agentdock/internal/skill" +) + +const ( + filesystemSkillIndexLimit = 50 + filesystemSkillDescriptionBytes = 120 + filesystemSkillDocumentMaxBytes = 1 << 20 +) + +type filesystemSkillScanOptions struct { + // common Skills historically allow package directories to be symlinks. + // workspace-local Skills do not follow package-directory symlinks outside + // the selected workspace root. + AllowPackageSymlinks bool +} + +type filesystemSkillItem struct { + Name string + Description string + File string +} + +type filesystemSkillIndex struct { + Items []filesystemSkillItem + Total int + Truncated bool +} + +// scanFilesystemSkills shares metadata parsing, stable ordering, and truncation. +// The caller chooses only the package-directory symlink policy so existing +// common Skill behavior is preserved without weakening workspace isolation. +func scanFilesystemSkills(root string, options filesystemSkillScanOptions) (filesystemSkillIndex, error) { + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return filesystemSkillIndex{Items: []filesystemSkillItem{}}, nil + } + return filesystemSkillIndex{}, err + } + + items := make([]filesystemSkillItem, 0, len(entries)) + for _, entry := range entries { + packageDir := filepath.Join(root, entry.Name()) + var info os.FileInfo + var statErr error + if options.AllowPackageSymlinks { + info, statErr = os.Stat(packageDir) + } else { + info, statErr = os.Lstat(packageDir) + } + if statErr != nil || !info.IsDir() { + continue + } + documentPath := filepath.Join(packageDir, "SKILL.md") + data, readErr := readFilesystemSkillDocument(packageDir, options.AllowPackageSymlinks) + if readErr != nil { + continue + } + metadata, parseErr := skills.ParseSkillMetadata(data) + if parseErr != nil { + continue + } + items = append(items, filesystemSkillItem{ + Name: metadata.Name, + Description: truncateString(strings.TrimSpace(metadata.Description), filesystemSkillDescriptionBytes), + File: documentPath, + }) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].Name == items[j].Name { + return items[i].File < items[j].File + } + return items[i].Name < items[j].Name + }) + index := filesystemSkillIndex{Items: items, Total: len(items)} + if index.Total > filesystemSkillIndexLimit { + index.Truncated = true + index.Items = index.Items[:filesystemSkillIndexLimit] + } + return index, nil +} + +func readFilesystemSkillDocument(packageDir string, allowSymlinks bool) ([]byte, error) { + if allowSymlinks { + // Preserve the historical common-Skill behavior: package/document + // symlinks are allowed, but reads are still bounded for indexing. + file, err := os.Open(filepath.Join(packageDir, "SKILL.md")) + if err != nil { + return nil, err + } + defer file.Close() + return readBoundedSkillDocument(file) + } + + // Workspace-local Skills are indexes for the selected repository, so do + // not let a leaf symlink escape the package directory. + root, err := os.OpenRoot(packageDir) + if err != nil { + return nil, err + } + defer root.Close() + before, err := root.Lstat("SKILL.md") + if err != nil || !before.Mode().IsRegular() { + return nil, os.ErrInvalid + } + file, err := root.Open("SKILL.md") + if err != nil { + return nil, err + } + defer file.Close() + after, err := file.Stat() + if err != nil || !after.Mode().IsRegular() || !os.SameFile(before, after) { + return nil, os.ErrInvalid + } + return readBoundedSkillDocument(file) +} + +func readBoundedSkillDocument(file *os.File) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(file, filesystemSkillDocumentMaxBytes+1)) + if err != nil { + return nil, err + } + if len(data) > filesystemSkillDocumentMaxBytes { + return nil, os.ErrInvalid + } + return data, nil +} diff --git a/internal/app/mcp_contract_test.go b/internal/app/mcp_contract_test.go index 55df6eed..9796cd8f 100644 --- a/internal/app/mcp_contract_test.go +++ b/internal/app/mcp_contract_test.go @@ -24,7 +24,8 @@ func TestCanonicalToolDefinitionsMatchSharedContract(t *testing.T) { t.Fatalf("canonical tool %s missing", name) } wantInput, _ := mcpcontract.InputSchema(name) - if !reflect.DeepEqual(definition.InputSchema, wantInput) { + actualInput, actualOutput := definition.InputSchema, definition.OutputSchema + if !reflect.DeepEqual(actualInput, wantInput) { t.Fatalf("%s input schema drifted from shared contract", name) } var wantOutput map[string]any @@ -33,7 +34,7 @@ func TestCanonicalToolDefinitionsMatchSharedContract(t *testing.T) { } else { wantOutput, _ = mcpcontract.OutputSchema(name) } - if !reflect.DeepEqual(definition.OutputSchema, wantOutput) { + if !reflect.DeepEqual(actualOutput, wantOutput) { t.Fatalf("%s output schema drifted from shared contract", name) } diff --git a/internal/app/output_contract_coverage_test.go b/internal/app/output_contract_coverage_test.go index d283da37..95d23c34 100644 --- a/internal/app/output_contract_coverage_test.go +++ b/internal/app/output_contract_coverage_test.go @@ -16,6 +16,7 @@ type outputContractCoverageEntry struct { // ToolDefinitions 是公开工具定义的单一事实源,tools/list 从同一 registry 派生;新增工具但忘记补契约测试时,门禁会直接失败。 var outputContractCoverageInventory = map[string]outputContractCoverageEntry{ "agentdock_context": {Variants: []string{"success"}}, + "workspace_context": {Variants: []string{"success"}}, "read_file": {Variants: []string{"success"}}, "list_dir": {Variants: []string{"success"}}, "search_text": {Variants: []string{"success"}}, diff --git a/internal/app/specs_context.go b/internal/app/specs_context.go index 8a238c83..49b750dd 100644 --- a/internal/app/specs_context.go +++ b/internal/app/specs_context.go @@ -1,9 +1,20 @@ package app func contextToolSpecs() []ToolSpec { - return []ToolSpec{{ - Name: "agentdock_context", Contract: canonicalToolContract, Title: "AgentDock context", - Description: "Return structured AgentDock bootstrap context including available capabilities, integrations, rules, and high-priority context.", - Handler: ctxToolHandler((*Runtime).agentDockContextTool), - }} + return []ToolSpec{ + { + Name: "agentdock_context", + Contract: canonicalToolContract, + Title: "AgentDock context", + Description: "Return structured AgentDock bootstrap context including available capabilities, integrations, rules, and high-priority context.", + Handler: ctxToolHandler((*Runtime).agentDockContextTool), + }, + { + Name: "workspace_context", + Contract: canonicalToolContract, + Title: "Workspace context", + Description: "Read the current workspace AGENTS.md inheritance chain and workspace-local Skill index. Call before project operations, after switching workspaces, or when workspace rules may have changed. workdir selection is request-local and does not change command defaults.", + Handler: ctxToolHandler((*Runtime).workspaceContextTool), + }, + } } diff --git a/internal/app/workspace_context.go b/internal/app/workspace_context.go new file mode 100644 index 00000000..1da0673c --- /dev/null +++ b/internal/app/workspace_context.go @@ -0,0 +1,96 @@ +package app + +import ( + "context" + "os" + "path/filepath" + + "github.com/uvwt/agentdock/internal/agentinstructions" +) + +type workspaceContextRequest struct { + Workdir string `json:"workdir,omitempty"` +} + +type workspaceContextResult struct { + Workdir string `json:"workdir"` + WorkspaceRoot string `json:"workspace_root"` + Instructions []agentinstructions.File `json:"instructions"` + WorkspaceSkills []workspaceSkillItem `json:"workspace_skills"` + Warnings []capabilityWarning `json:"warnings"` +} + +type workspaceSkillItem struct { + Name string `json:"name"` + Description string `json:"description"` + File string `json:"file"` +} + +// workspaceContext 每次调用都从磁盘重新读取当前工作区规则与本地 Skill 索引。 +// workdir 只用于本次选择,不修改 Workspace 默认 cwd,也不保存为 Runtime 状态。 +func (r *Runtime) workspaceContext(ctx context.Context, workdir string) (Result, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + resolved, err := r.ws.ResolveExisting(workdir) + if err != nil { + return nil, toolErrorDetails("INVALID_ARGUMENT", "workspace workdir must resolve to an existing host directory", "validation", map[string]any{"workdir": workdir}) + } + info, err := os.Stat(resolved.Abs) + if err != nil || !info.IsDir() { + return nil, toolErrorDetails("INVALID_ARGUMENT", "workspace workdir must be a directory", "validation", map[string]any{"workdir": workdir}) + } + + // 全局 AGENTS.md 只有一个固定位置;它不受 AGENTDOCK_HOME 等运行目录配置影响。 + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + instructions, err := agentinstructions.Load(ctx, agentinstructions.Options{ + Home: filepath.Join(home, ".agentdock"), + DefaultDir: r.ws.Root(), + Workdir: resolved.Abs, + }) + if err != nil { + return nil, err + } + + warnings := []capabilityWarning{} + workspaceSkills := []workspaceSkillItem{} + skillRoot := filepath.Join(instructions.WorkspaceRoot, ".agents", "skills") + skillIndex, skillErr := scanFilesystemSkills(skillRoot, filesystemSkillScanOptions{}) + if skillErr != nil { + warnings = append(warnings, capabilityWarning{Source: "workspace_skills", Message: "工作区 Skill 索引暂不可用。"}) + } else { + workspaceSkills = make([]workspaceSkillItem, 0, len(skillIndex.Items)) + for _, item := range skillIndex.Items { + workspaceSkills = append(workspaceSkills, workspaceSkillItem{ + Name: item.Name, Description: item.Description, File: item.File, + }) + } + if skillIndex.Truncated { + warnings = append(warnings, capabilityWarning{Source: "workspace_skills", Message: "工作区 Skill 数量超过索引上限,仅返回稳定排序后的前 50 项。"}) + } + } + + value := workspaceContextResult{ + Workdir: instructions.Workdir, + WorkspaceRoot: instructions.WorkspaceRoot, + Instructions: instructions.Files, + WorkspaceSkills: workspaceSkills, + Warnings: warnings, + } + var result Result + if err := remarshal(value, &result); err != nil { + return nil, err + } + return result, nil +} + +func (r *Runtime) workspaceContextTool(ctx context.Context, args map[string]any) (Result, error) { + var request workspaceContextRequest + if err := decodeToolInput("workspace_context", args, &request); err != nil { + return nil, err + } + return r.workspaceContext(ctx, request.Workdir) +} diff --git a/internal/app/workspace_context_test.go b/internal/app/workspace_context_test.go new file mode 100644 index 00000000..1a49b9dc --- /dev/null +++ b/internal/app/workspace_context_test.go @@ -0,0 +1,278 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/uvwt/agentdock/internal/agentinstructions" + "github.com/uvwt/agentdock/internal/config" +) + +func newWorkspaceContextRuntime(t *testing.T) (*Runtime, string) { + t.Helper() + home := t.TempDir() + setUserHomeForTest(t, home) + cfg := config.Config{ + AgentDockHome: filepath.Join(home, "agentdock-state"), + AgentDockDefaultDir: filepath.Join(home, "default-workspace"), + } + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + rt, err := NewRuntime(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := rt.Close(); err != nil { + t.Error(err) + } + }) + return rt, home +} + +func writeWorkspaceInstruction(t *testing.T, dir, content string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, agentinstructions.Filename) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func callWorkspaceContext(t *testing.T, rt *Runtime, args map[string]any) workspaceContextResult { + t.Helper() + result, err := rt.Call(t.Context(), "workspace_context", args) + if err != nil { + t.Fatal(err) + } + assertToolResultMatchestestOutputSchema(t, "workspace_context", result) + var got workspaceContextResult + if err := remarshal(result, &got); err != nil { + t.Fatal(err) + } + return got +} + +func loadedWorkspaceInstructions(value workspaceContextResult) string { + contents := make([]string, 0, len(value.Instructions)) + for _, file := range value.Instructions { + if file.Status == "loaded" { + contents = append(contents, file.Content) + } + } + return strings.Join(contents, "|") +} + +func TestWorkspaceContextLoadsFixedGlobalNestedRulesAndLocalSkills(t *testing.T) { + rt, home := newWorkspaceContextRuntime(t) + root := rt.ws.Root() + child := filepath.Join(root, "service", "internal") + if err := os.MkdirAll(child, 0o700); err != nil { + t.Fatal(err) + } + writeWorkspaceInstruction(t, filepath.Join(home, ".agentdock"), "fixed-global") + writeWorkspaceInstruction(t, rt.cfg.AgentDockHome, "configured-home-must-not-load") + writeWorkspaceInstruction(t, root, "workspace-root") + writeWorkspaceInstruction(t, filepath.Dir(child), "workspace-service") + writeWorkspaceInstruction(t, child, "workspace-child") + writeCommonSkillForTest(t, filepath.Join(root, ".agents", "skills"), "z-dir", "z-skill", "Z workspace skill") + writeCommonSkillForTest(t, filepath.Join(root, ".agents", "skills"), "a-dir", "a-skill", "A workspace skill") + writeCommonSkillFileForTest(t, filepath.Join(root, ".agents", "skills", "bad", "SKILL.md"), "not frontmatter") + + got := callWorkspaceContext(t, rt, map[string]any{"workdir": child}) + if got.Workdir != child || got.WorkspaceRoot != root { + t.Fatalf("workspace selection = %#v", got) + } + if body := loadedWorkspaceInstructions(got); body != "fixed-global|workspace-root|workspace-service|workspace-child" { + t.Fatalf("instruction order/body = %q", body) + } + if len(got.WorkspaceSkills) != 2 || got.WorkspaceSkills[0].Name != "a-skill" || got.WorkspaceSkills[1].Name != "z-skill" { + t.Fatalf("workspace Skill index = %#v", got.WorkspaceSkills) + } + if got.WorkspaceSkills[0].File != filepath.Join(root, ".agents", "skills", "a-dir", "SKILL.md") { + t.Fatalf("workspace Skill file = %q", got.WorkspaceSkills[0].File) + } + encoded, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "Follow this workflow") || strings.Contains(string(encoded), "configured-home-must-not-load") { + t.Fatalf("workspace context leaked Skill body or configured AgentDockHome rule: %s", encoded) + } +} + +func TestWorkspaceContextSwitchingIsFreshAndDoesNotChangeDefaultCWD(t *testing.T) { + rt, home := newWorkspaceContextRuntime(t) + writeWorkspaceInstruction(t, filepath.Join(home, ".agentdock"), "global") + defaultCWD := rt.ws.DefaultCWD() + projects := []string{t.TempDir(), t.TempDir()} + for index, project := range projects { + if err := os.Mkdir(filepath.Join(project, ".git"), 0o700); err != nil { + t.Fatal(err) + } + writeWorkspaceInstruction(t, project, fmt.Sprintf("project-%d", index)) + } + + first := callWorkspaceContext(t, rt, map[string]any{"workdir": projects[0]}) + second := callWorkspaceContext(t, rt, map[string]any{"workdir": projects[1]}) + if loadedWorkspaceInstructions(first) != "global|project-0" || loadedWorkspaceInstructions(second) != "global|project-1" { + t.Fatalf("workspace switch leaked context: first=%q second=%q", loadedWorkspaceInstructions(first), loadedWorkspaceInstructions(second)) + } + writeWorkspaceInstruction(t, projects[0], "project-X") + refreshed := callWorkspaceContext(t, rt, map[string]any{"workdir": projects[0]}) + if loadedWorkspaceInstructions(refreshed) != "global|project-X" { + t.Fatalf("workspace refresh returned stale content: %q", loadedWorkspaceInstructions(refreshed)) + } + if err := os.Remove(filepath.Join(projects[1], agentinstructions.Filename)); err != nil { + t.Fatal(err) + } + deleted := callWorkspaceContext(t, rt, map[string]any{"workdir": projects[1]}) + if len(deleted.Instructions) != 2 || deleted.Instructions[1].Status != "not_found" { + t.Fatalf("deleted AGENTS.md remained loaded: %#v", deleted.Instructions) + } + if rt.ws.DefaultCWD() != defaultCWD { + t.Fatalf("workspace_context changed default cwd: got=%q want=%q", rt.ws.DefaultCWD(), defaultCWD) + } +} + +func TestWorkspaceContextConcurrentSelectionsRemainIsolated(t *testing.T) { + rt, home := newWorkspaceContextRuntime(t) + writeWorkspaceInstruction(t, filepath.Join(home, ".agentdock"), "global") + projects := []string{t.TempDir(), t.TempDir()} + for index, project := range projects { + if err := os.Mkdir(filepath.Join(project, ".git"), 0o700); err != nil { + t.Fatal(err) + } + writeWorkspaceInstruction(t, project, fmt.Sprintf("project-%d", index)) + } + defaultCWD := rt.ws.DefaultCWD() + + var wg sync.WaitGroup + for index := range 16 { + wg.Add(1) + go func(index int) { + defer wg.Done() + result, err := rt.Call(t.Context(), "workspace_context", map[string]any{"workdir": projects[index%2]}) + if err != nil { + t.Error(err) + return + } + var got workspaceContextResult + if err := remarshal(result, &got); err != nil { + t.Error(err) + return + } + want := fmt.Sprintf("global|project-%d", index%2) + if body := loadedWorkspaceInstructions(got); body != want { + t.Errorf("workspace context leaked across requests: got=%q want=%q", body, want) + } + }(index) + } + wg.Wait() + if rt.ws.DefaultCWD() != defaultCWD { + t.Fatal("concurrent workspace selection changed default cwd") + } +} + +func TestWorkspaceContextReportsBoundedInvalidAndOversizedInstructions(t *testing.T) { + rt, home := newWorkspaceContextRuntime(t) + global := filepath.Join(home, ".agentdock") + if err := os.MkdirAll(global, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(global, agentinstructions.Filename), []byte{0xff, 0xfe}, 0o600); err != nil { + t.Fatal(err) + } + writeWorkspaceInstruction(t, rt.ws.Root(), strings.Repeat("x", agentinstructions.MaxFileBytes+1)) + + got := callWorkspaceContext(t, rt, nil) + if len(got.Instructions) != 2 || got.Instructions[0].Reason != "invalid_utf8_text" || got.Instructions[1].Reason != "file_size_limit" { + t.Fatalf("invalid instruction status = %#v", got.Instructions) + } + for _, file := range got.Instructions { + if file.Content != "" { + t.Fatalf("invalid instruction returned partial body: %#v", file) + } + } +} + +func TestWorkspaceContextSkillIndexTruncatesWithWarning(t *testing.T) { + rt, _ := newWorkspaceContextRuntime(t) + root := filepath.Join(rt.ws.Root(), ".agents", "skills") + for index := 0; index < filesystemSkillIndexLimit+2; index++ { + name := fmt.Sprintf("skill-%02d", index) + writeCommonSkillForTest(t, root, name, name, "workspace skill") + } + got := callWorkspaceContext(t, rt, nil) + if len(got.WorkspaceSkills) != filesystemSkillIndexLimit || len(got.Warnings) != 1 || got.Warnings[0].Source != "workspace_skills" { + t.Fatalf("truncated workspace Skill index = %#v warnings=%#v", got.WorkspaceSkills, got.Warnings) + } +} + +func TestWorkspaceContextRejectsInvalidInputAndHonorsCancellation(t *testing.T) { + rt, _ := newWorkspaceContextRuntime(t) + file := writeWorkspaceInstruction(t, rt.ws.Root(), "rules") + for _, args := range []map[string]any{ + {"workdir": 42}, {"workdir": nil}, {"workdir": file}, + {"workdir": filepath.Join(rt.ws.Root(), "missing")}, {"unknown": true}, + } { + _, err := rt.Call(t.Context(), "workspace_context", args) + var toolErr *ToolError + if !errors.As(err, &toolErr) || toolErr.Code != "INVALID_ARGUMENT" { + t.Fatalf("args=%#v error=%v", args, err) + } + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := rt.Call(ctx, "workspace_context", nil); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation=%v", err) + } +} + +func TestWorkspaceContextDoesNotFollowWorkspaceSkillPackageSymlink(t *testing.T) { + rt, _ := newWorkspaceContextRuntime(t) + root := filepath.Join(rt.ws.Root(), ".agents", "skills") + targetRoot := t.TempDir() + writeCommonSkillForTest(t, targetRoot, "outside-skill", "outside-skill", "Outside workspace skill.") + target := filepath.Join(targetRoot, "outside-skill") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(root, "linked")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + got := callWorkspaceContext(t, rt, nil) + if len(got.WorkspaceSkills) != 0 { + t.Fatalf("workspace Skill index followed package symlink outside workspace: %#v", got.WorkspaceSkills) + } +} + +func TestWorkspaceContextDoesNotFollowWorkspaceSkillDocumentSymlink(t *testing.T) { + rt, _ := newWorkspaceContextRuntime(t) + packageDir := filepath.Join(rt.ws.Root(), ".agents", "skills", "linked-skill") + if err := os.MkdirAll(packageDir, 0o700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "SKILL.md") + writeCommonSkillFileForTest(t, outside, "---\nname: outside-skill\ndescription: Outside workspace skill.\n---\n\n# Outside\n") + if err := os.Symlink(outside, filepath.Join(packageDir, "SKILL.md")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + got := callWorkspaceContext(t, rt, nil) + if len(got.WorkspaceSkills) != 0 { + t.Fatalf("workspace Skill index followed SKILL.md symlink outside workspace: %#v", got.WorkspaceSkills) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 197bda44..076a7298 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net" "net/url" "os" @@ -12,7 +11,6 @@ import ( "strconv" "strings" "time" - "unicode/utf8" "github.com/uvwt/agentdock/internal/fs/securepath" ) @@ -23,8 +21,6 @@ const ( PathModel = "host" RecallTimeoutMS = 30000 - maxInstructionsFileBytes = 64 << 10 - defaultOAuthAccessTokenTTLSeconds = int64(time.Hour / time.Second) maxOAuthAccessTokenTTLSeconds = int64(999999 * 24 * 60 * 60) ) @@ -55,8 +51,6 @@ type Config struct { ACPInteractionMS int Stdio bool TrustedProxyCIDRs []string - InstructionsFile string - Instructions string } // ACPProfile 表示一个可独立运行、独立持久化会话的 ACP 实例。 @@ -162,7 +156,6 @@ func FromEnv() (Config, error) { ACPInteractionMS: acpInteractionMS, Stdio: stdio, TrustedProxyCIDRs: splitCommaSeparated(os.Getenv("AGENTDOCK_TRUSTED_PROXY_CIDRS")), - InstructionsFile: strings.TrimSpace(os.Getenv("AGENTDOCK_INSTRUCTIONS_FILE")), }, nil } @@ -226,47 +219,6 @@ func (c *Config) Normalize() error { return fmt.Errorf("BrowserCDPURL must use http, https, ws, or wss: %s", c.BrowserCDPURL) } } - c.InstructionsFile = strings.TrimSpace(c.InstructionsFile) - if c.InstructionsFile != "" { - c.InstructionsFile = filepath.Clean(c.InstructionsFile) - if !filepath.IsAbs(c.InstructionsFile) { - return fmt.Errorf("InstructionsFile must resolve to an absolute path: %s", c.InstructionsFile) - } - - // 先检查文件类型再打开,避免误配设备或命名管道时在 Open 阶段阻塞。 - info, err := os.Stat(c.InstructionsFile) - if err != nil { - return fmt.Errorf("stat InstructionsFile %s: %w", c.InstructionsFile, err) - } - if !info.Mode().IsRegular() { - return fmt.Errorf("InstructionsFile must be a regular file: %s", c.InstructionsFile) - } - if info.Size() > maxInstructionsFileBytes { - return fmt.Errorf("InstructionsFile %s exceeds %d bytes", c.InstructionsFile, maxInstructionsFileBytes) - } - - file, err := os.Open(c.InstructionsFile) - if err != nil { - return fmt.Errorf("open InstructionsFile %s: %w", c.InstructionsFile, err) - } - defer file.Close() - - // Stat 只能约束检查瞬间的文件大小;读取仍限制为 max+1,避免文件并发增长时突破边界。 - data, err := io.ReadAll(io.LimitReader(file, int64(maxInstructionsFileBytes)+1)) - if err != nil { - return fmt.Errorf("read InstructionsFile %s: %w", c.InstructionsFile, err) - } - if len(data) > maxInstructionsFileBytes { - return fmt.Errorf("InstructionsFile %s exceeds %d bytes", c.InstructionsFile, maxInstructionsFileBytes) - } - if !utf8.Valid(data) { - return fmt.Errorf("InstructionsFile must contain valid UTF-8: %s", c.InstructionsFile) - } - c.Instructions = strings.TrimSpace(string(data)) - if c.Instructions == "" { - return fmt.Errorf("InstructionsFile must contain non-empty instructions: %s", c.InstructionsFile) - } - } if err := validateEnvironmentMapping(c.CommandEnvFromEnv); err != nil { return fmt.Errorf("AGENTDOCK_COMMAND_ENV_FROM_ENV_JSON: %w", err) } diff --git a/internal/config/instructions_test.go b/internal/config/instructions_test.go deleted file mode 100644 index 4bab954e..00000000 --- a/internal/config/instructions_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestFromEnvLoadsInstructionsFile(t *testing.T) { - setTestUserHome(t, t.TempDir()) - path := filepath.Join(t.TempDir(), "instructions.md") - if err := os.WriteFile(path, []byte("\n# Guide\n\nUse absolute paths.\n"), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - t.Setenv("AGENTDOCK_INSTRUCTIONS_FILE", path) - - cfg, err := FromEnv() - if err != nil { - t.Fatalf("FromEnv() error = %v", err) - } - if err := cfg.Normalize(); err != nil { - t.Fatalf("Normalize() error = %v", err) - } - if cfg.Instructions != "# Guide\n\nUse absolute paths." { - t.Fatalf("Instructions = %q", cfg.Instructions) - } -} - -func TestNormalizeRejectsRelativeInstructionsFile(t *testing.T) { - setTestUserHome(t, t.TempDir()) - t.Setenv("AGENTDOCK_INSTRUCTIONS_FILE", "relative/instructions.md") - cfg, err := FromEnv() - if err != nil { - t.Fatalf("FromEnv() error = %v", err) - } - err = cfg.Normalize() - if err == nil || !strings.Contains(err.Error(), "InstructionsFile") { - t.Fatalf("Normalize() error = %v, want InstructionsFile", err) - } -} - -func TestNormalizeFailsWhenInstructionsFileMissing(t *testing.T) { - setTestUserHome(t, t.TempDir()) - t.Setenv("AGENTDOCK_INSTRUCTIONS_FILE", filepath.Join(t.TempDir(), "missing.md")) - cfg, err := FromEnv() - if err != nil { - t.Fatalf("FromEnv() error = %v", err) - } - if err := cfg.Normalize(); err == nil { - t.Fatal("Normalize() accepted a missing instructions file") - } -} - -func TestNormalizeAcceptsInstructionsFileAtSizeLimit(t *testing.T) { - setTestUserHome(t, t.TempDir()) - path := filepath.Join(t.TempDir(), "instructions.md") - content := strings.Repeat("a", maxInstructionsFileBytes) - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - t.Setenv("AGENTDOCK_INSTRUCTIONS_FILE", path) - - cfg, err := FromEnv() - if err != nil { - t.Fatalf("FromEnv() error = %v", err) - } - if err := cfg.Normalize(); err != nil { - t.Fatalf("Normalize() error = %v", err) - } - if cfg.Instructions != content { - t.Fatalf("Instructions length = %d, want %d", len(cfg.Instructions), len(content)) - } -} - -func TestNormalizeRejectsInstructionsFileOverSizeLimit(t *testing.T) { - setTestUserHome(t, t.TempDir()) - path := filepath.Join(t.TempDir(), "instructions.md") - if err := os.WriteFile(path, []byte(strings.Repeat("a", maxInstructionsFileBytes+1)), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - t.Setenv("AGENTDOCK_INSTRUCTIONS_FILE", path) - - cfg, err := FromEnv() - if err != nil { - t.Fatalf("FromEnv() error = %v", err) - } - if err := cfg.Normalize(); err == nil || !strings.Contains(err.Error(), "exceeds") { - t.Fatalf("Normalize() error = %v, want size limit error", err) - } -} - -func TestNormalizeRejectsEmptyInstructionsFile(t *testing.T) { - for _, test := range []struct { - name string - content string - }{ - {name: "empty", content: ""}, - {name: "whitespace only", content: " \n\t "}, - } { - t.Run(test.name, func(t *testing.T) { - setTestUserHome(t, t.TempDir()) - path := filepath.Join(t.TempDir(), "instructions.md") - if err := os.WriteFile(path, []byte(test.content), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - t.Setenv("AGENTDOCK_INSTRUCTIONS_FILE", path) - - cfg, err := FromEnv() - if err != nil { - t.Fatalf("FromEnv() error = %v", err) - } - if err := cfg.Normalize(); err == nil || !strings.Contains(err.Error(), "non-empty") { - t.Fatalf("Normalize() error = %v, want non-empty instructions error", err) - } - }) - } -} - -func TestNormalizeRejectsInvalidUTF8InstructionsFile(t *testing.T) { - setTestUserHome(t, t.TempDir()) - path := filepath.Join(t.TempDir(), "instructions.md") - if err := os.WriteFile(path, []byte{0xff, 0xfe}, 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - t.Setenv("AGENTDOCK_INSTRUCTIONS_FILE", path) - - cfg, err := FromEnv() - if err != nil { - t.Fatalf("FromEnv() error = %v", err) - } - if err := cfg.Normalize(); err == nil || !strings.Contains(err.Error(), "UTF-8") { - t.Fatalf("Normalize() error = %v, want UTF-8 error", err) - } -} - -func TestNormalizeRejectsNonRegularInstructionsFile(t *testing.T) { - setTestUserHome(t, t.TempDir()) - t.Setenv("AGENTDOCK_INSTRUCTIONS_FILE", t.TempDir()) - - cfg, err := FromEnv() - if err != nil { - t.Fatalf("FromEnv() error = %v", err) - } - if err := cfg.Normalize(); err == nil || !strings.Contains(err.Error(), "regular file") { - t.Fatalf("Normalize() error = %v, want regular file error", err) - } -} diff --git a/internal/mcp/apps_test.go b/internal/mcp/apps_test.go index 725e4b8e..2140d1a2 100644 --- a/internal/mcp/apps_test.go +++ b/internal/mcp/apps_test.go @@ -202,8 +202,8 @@ func TestMCPAppsBindResourcesDirectlyToBusinessTools(t *testing.T) { } tools[tool.Name] = tool } - if len(tools) != 16 { - t.Fatalf("tools/list count = %d, want 16", len(tools)) + if len(tools) != 17 { + t.Fatalf("tools/list count = %d, want 17", len(tools)) } contextTool := tools["agentdock_context"] if contextTool == nil { @@ -625,8 +625,8 @@ func TestMCPAppsExposeACPViewOnlyWhenACPEnabled(t *testing.T) { } tools[tool.Name] = tool } - if len(tools) != 19 { - t.Fatalf("tools/list count = %d, want 19", len(tools)) + if len(tools) != 20 { + t.Fatalf("tools/list count = %d, want 20", len(tools)) } assertToolUIResource(t, tools["acp_session"], protocol.ACPStatusUIResourceURI) for _, name := range []string{"acp_prompt", "acp_interaction"} { diff --git a/internal/mcp/instructions.go b/internal/mcp/instructions.go index 6d677424..fcd93663 100644 --- a/internal/mcp/instructions.go +++ b/internal/mcp/instructions.go @@ -1,20 +1,13 @@ package mcp -import "strings" - const ( - baseServerInstructions = "优先调用 `agentdock_context` 获取可用于操作用户设备的核心能力、Skill、动态 MCP 和重要上下文。处理多步骤任务时使用 `task_manage` 记录和维护任务进度。根据用户需求选择合适的能力检查、操作和验证设备状态。" - nexusServerInstructions = "优先调用 `agentdock_context` 获取可用于操作用户设备的核心能力、Skill、动态 MCP、Workflow 模板、重要上下文和长期记忆索引。需要查找或读取长期记忆时使用 `recall_*`;需要查找或使用 Workflow 模板时使用 `workflow_template_manage`;处理多步骤任务时使用 `task_manage` 记录和维护任务进度。根据用户需求选择合适的能力检查、操作和验证设备状态。" + baseServerInstructions = "优先调用 `agentdock_context` 获取可用于操作用户设备的核心能力、Skill、动态 MCP 和重要上下文。操作具体项目、切换工作区或工作区规则可能变化时,先调用 `workspace_context` 获取当前工作区上下文。处理多步骤任务时使用 `task_manage` 记录和维护任务进度。根据用户需求选择合适的能力检查、操作和验证设备状态。" + nexusServerInstructions = "优先调用 `agentdock_context` 获取可用于操作用户设备的核心能力、Skill、动态 MCP、Workflow 模板、重要上下文和长期记忆索引。操作具体项目、切换工作区或工作区规则可能变化时,先调用节点范围的 `workspace_context` 获取当前工作区上下文。需要查找或读取长期记忆时使用 `recall_*`;需要查找或使用 Workflow 模板时使用 `workflow_template_manage`;处理多步骤任务时使用 `task_manage` 记录和维护任务进度。根据用户需求选择合适的能力检查、操作和验证设备状态。" ) -func serverInstructions(nexusEnabled bool, custom string) string { - instructions := baseServerInstructions +func serverInstructions(nexusEnabled bool) string { if nexusEnabled { - instructions = nexusServerInstructions - } - custom = strings.TrimSpace(custom) - if custom == "" { - return instructions + return nexusServerInstructions } - return instructions + "\n\nAdditional operator instructions:\n" + custom + return baseServerInstructions } diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go index df479b64..d522385f 100644 --- a/internal/mcp/registry_test.go +++ b/internal/mcp/registry_test.go @@ -82,7 +82,7 @@ func TestRuntimeExposesSingleToolSet(t *testing.T) { for _, name := range rt.ToolNames() { seen[name] = true } - for _, name := range []string{"agentdock_context", "session_observe", "session_act", "recall_read", "recall_write", "skill_package", "mcp_manage", "mcp_tool_search", "mcp_tool_inspect", "mcp_tool_call"} { + for _, name := range []string{"agentdock_context", "workspace_context", "session_observe", "session_act", "recall_read", "recall_write", "skill_package", "mcp_manage", "mcp_tool_search", "mcp_tool_inspect", "mcp_tool_call"} { if !seen[name] { t.Fatalf("single tool set missing %s: %#v", name, seen) } @@ -105,7 +105,10 @@ func TestAgentDockContextSchemaIsStructuredEntrypoint(t *testing.T) { inputProps := schemaProperties(t, "agentdock_context") if len(inputProps) != 0 { - t.Fatalf("agentdock_context input schema should not expose node-local selectors: %#v", inputProps) + t.Fatalf("agentdock_context should not select a workspace: %#v", inputProps) + } + if required, _ := inputSchema("agentdock_context")["required"].([]string); len(required) != 0 { + t.Fatalf("agentdock_context must still accept empty arguments: %#v", required) } output := outputSchema("agentdock_context") outputProps, ok := output["properties"].(map[string]any) @@ -117,6 +120,9 @@ func TestAgentDockContextSchemaIsStructuredEntrypoint(t *testing.T) { t.Fatalf("agentdock_context output schema missing %q: %#v", name, outputProps) } } + if _, leaked := outputProps["instruction_files"]; leaked { + t.Fatalf("agentdock_context must not expose workspace instructions: %#v", outputProps) + } if _, legacy := outputProps["context"]; legacy { t.Fatalf("agentdock_context output schema still exposes legacy Markdown context: %#v", outputProps) } @@ -126,6 +132,29 @@ func TestAgentDockContextSchemaIsStructuredEntrypoint(t *testing.T) { } } +func TestWorkspaceContextSchemaIsRequestLocalWorkspaceEntrypoint(t *testing.T) { + def, ok := toolDefinition("workspace_context") + if !ok { + t.Fatal("workspace_context definition missing") + } + if !strings.Contains(def.Description, "workspace AGENTS.md") { + t.Fatalf("workspace_context description should explain workspace rules: %q", def.Description) + } + inputProps := schemaProperties(t, "workspace_context") + if len(inputProps) != 1 || inputProps["workdir"] == nil { + t.Fatalf("workspace_context should expose only optional workdir: %#v", inputProps) + } + if required, _ := inputSchema("workspace_context")["required"].([]string); len(required) != 0 { + t.Fatalf("workspace_context must accept empty arguments: %#v", required) + } + outputProps := outputSchema("workspace_context")["properties"].(map[string]any) + for _, name := range []string{"workdir", "workspace_root", "instructions", "workspace_skills", "warnings"} { + if _, ok := outputProps[name]; !ok { + t.Fatalf("workspace_context output missing %q: %#v", name, outputProps) + } + } +} + func TestNexusDockRecallToolNamesHideLegacyMemoryTools(t *testing.T) { cfg := config.Config{ AgentDockDefaultDir: t.TempDir(), AgentDockHome: filepath.Join(t.TempDir(), ".agentdock"), diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b52a62f4..1bc48d9a 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -29,7 +29,7 @@ func NewServer(runtime *app.Runtime, cfg config.Config) *Server { server := &Server{runtime: runtime, cfg: cfg} serverOptions := &mcpsdk.ServerOptions{ Capabilities: &mcpsdk.ServerCapabilities{}, - Instructions: serverInstructions(cfg.NexusEndpoint != "", cfg.Instructions), + Instructions: serverInstructions(cfg.NexusEndpoint != ""), } server.sdk = mcpsdk.NewServer( &mcpsdk.Implementation{Name: config.ServerName, Version: buildinfo.Version}, diff --git a/internal/mcp/stdio_test.go b/internal/mcp/stdio_test.go index e6fee696..99af8d54 100644 --- a/internal/mcp/stdio_test.go +++ b/internal/mcp/stdio_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "os" "path/filepath" "strings" "testing" @@ -60,25 +61,14 @@ func TestServeStdioRejectsUninitializedServer(t *testing.T) { } } -func TestServeStdioAdvertisesContextAwareInstructions(t *testing.T) { +func TestServeStdioAdvertisesStableContextInstructions(t *testing.T) { tests := []struct { - name string - cfg config.Config - wantPrefix string + name string + cfg config.Config + want string }{ - { - name: "without Nexus", - cfg: config.Config{Instructions: "Use absolute paths under /srv."}, - wantPrefix: baseServerInstructions, - }, - { - name: "with Nexus", - cfg: config.Config{ - NexusEndpoint: "http://127.0.0.1:18777", - Instructions: "Use absolute paths under /srv.", - }, - wantPrefix: nexusServerInstructions, - }, + {name: "without Nexus", cfg: config.Config{}, want: baseServerInstructions}, + {name: "with Nexus", cfg: config.Config{NexusEndpoint: "http://127.0.0.1:18777"}, want: nexusServerInstructions}, } for _, test := range tests { @@ -102,11 +92,11 @@ func TestServeStdioAdvertisesContextAwareInstructions(t *testing.T) { t.Fatalf("Connect() error = %v", err) } result := session.InitializeResult() - if result == nil || !strings.HasPrefix(result.Instructions, test.wantPrefix) { - t.Fatalf("InitializeResult() = %#v, want %q first", result, test.wantPrefix) + if result == nil || result.Instructions != test.want { + t.Fatalf("InitializeResult() = %#v, want stable instructions %q", result, test.want) } - if !strings.Contains(result.Instructions, "Additional operator instructions:\nUse absolute paths under /srv.") { - t.Fatalf("InitializeResult() = %#v, want operator instructions appended", result) + if !strings.Contains(result.Instructions, "workspace_context") { + t.Fatalf("stable instructions do not explain workspace_context: %q", result.Instructions) } if err := session.Close(); err != nil { t.Fatalf("Close() error = %v", err) @@ -123,6 +113,67 @@ func TestServeStdioAdvertisesContextAwareInstructions(t *testing.T) { } } +func TestServeStdioDoesNotInjectAGENTSContent(t *testing.T) { + root := t.TempDir() + cfg := config.Config{ + AgentDockHome: filepath.Join(root, "state"), + AgentDockDefaultDir: filepath.Join(root, "workspace"), + } + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + for path, marker := range map[string]string{ + filepath.Join(cfg.AgentDockHome, "AGENTS.md"): "GLOBAL-AGENTS-MARKER", + filepath.Join(cfg.AgentDockDefaultDir, "AGENTS.md"): "WORKSPACE-AGENTS-MARKER", + } { + if err := os.WriteFile(path, []byte(marker), 0o600); err != nil { + t.Fatal(err) + } + } + runtime, err := app.NewRuntime(cfg) + if err != nil { + t.Fatal(err) + } + defer func() { _ = runtime.Close() }() + + server := NewServer(runtime, cfg) + clientInput, serverOutput := io.Pipe() + serverInput, clientOutput := io.Pipe() + serverDone := make(chan error, 1) + go func() { serverDone <- server.ServeStdio(serverInput, serverOutput) }() + + client := mcpsdk.NewClient( + &mcpsdk.Implementation{Name: "agentdock-instructions-test", Version: "1.0.0"}, + &mcpsdk.ClientOptions{Capabilities: &mcpsdk.ClientCapabilities{}}, + ) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + session, err := client.Connect(ctx, &mcpsdk.IOTransport{Reader: clientInput, Writer: clientOutput}, nil) + if err != nil { + t.Fatal(err) + } + result := session.InitializeResult() + if result == nil || result.Instructions != baseServerInstructions { + t.Fatalf("InitializeResult() = %#v, want stable instructions only", result) + } + for _, marker := range []string{"GLOBAL-AGENTS-MARKER", "WORKSPACE-AGENTS-MARKER"} { + if strings.Contains(result.Instructions, marker) { + t.Fatalf("MCP initialize injected AGENTS.md content %q", marker) + } + } + if err := session.Close(); err != nil { + t.Fatal(err) + } + select { + case err := <-serverDone: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("ServeStdio() did not stop after client close") + } +} + func TestServeStdioHidesNexusExtensionsWithoutNexus(t *testing.T) { cfg := config.Config{ AgentDockDefaultDir: t.TempDir(),