From bf5c7865869d63d40098306ed94ba26e1c4c7c47 Mon Sep 17 00:00:00 2001 From: AmorFati <3093356744@QQ.COM> Date: Tue, 15 Sep 2026 02:01:11 +0800 Subject: [PATCH 1/2] feat(context): auto-load global and workspace AGENTS.md Make operator and project guidance available through MCP initialization and agentdock_context without requiring separate file reads, ACP, or Recall. Discover the explicit global instructions file or the default AGENTS.md, then load workspace guidance from the applicable root to the selected directory. Return ordered content with provenance, hashes, and explicit load/skip/error states. Read fresh files on each context request. Add an optional request-local workdir selector without changing command working directories or sharing workspace selection across clients. Keep empty-argument calls compatible and preserve the private Nexus context shape through the existing rules field. Bound discovery and content sizes, validate UTF-8, reject automatically discovered symlinks and non-regular files, and deduplicate physical files. Preserve explicit instructions-file behavior and provide an autoload opt-out through AGENTDOCK_AGENTS_AUTOLOAD. Cover discovery, refresh, scope boundaries, Windows path casing, file validation, concurrent workspace isolation, schema compatibility, and MCP initialization/live-context delivery with regression tests. Document the loading contract, configuration, and startup-snapshot limitations. --- .env.example | 6 + docs/agents-context.md | 81 +++++ internal/agentinstructions/instructions.go | 276 +++++++++++++++++ .../agentinstructions/instructions_test.go | 289 ++++++++++++++++++ .../instructions_unix_test.go | 37 +++ .../instructions_windows_test.go | 18 ++ internal/agentinstructions/open_other.go | 7 + internal/agentinstructions/open_unix.go | 14 + internal/app/agentdock_context.go | 33 +- internal/app/contract_drift_test.go | 1 + internal/app/instruction_files.go | 31 ++ internal/app/instruction_files_test.go | 241 +++++++++++++++ internal/app/mcp_contract_test.go | 30 +- internal/app/specs_context.go | 65 +++- internal/config/agents_autoload_test.go | 36 +++ internal/config/config.go | 6 + internal/mcp/bootstrap_instructions.go | 30 ++ internal/mcp/bootstrap_instructions_test.go | 164 ++++++++++ internal/mcp/registry_test.go | 9 +- internal/mcp/server.go | 2 +- 20 files changed, 1363 insertions(+), 13 deletions(-) create mode 100644 docs/agents-context.md create mode 100644 internal/agentinstructions/instructions.go create mode 100644 internal/agentinstructions/instructions_test.go create mode 100644 internal/agentinstructions/instructions_unix_test.go create mode 100644 internal/agentinstructions/instructions_windows_test.go create mode 100644 internal/agentinstructions/open_other.go create mode 100644 internal/agentinstructions/open_unix.go create mode 100644 internal/app/instruction_files.go create mode 100644 internal/app/instruction_files_test.go create mode 100644 internal/config/agents_autoload_test.go create mode 100644 internal/mcp/bootstrap_instructions.go create mode 100644 internal/mcp/bootstrap_instructions_test.go diff --git a/.env.example b/.env.example index 67369c76..fd2e9d56 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,9 @@ TUNNEL_TOKEN=replace-with-cloudflare-tunnel-token # 可选:浏览器自动化镜像。 # AGENTDOCK_IMAGE=ghcr.io/uvwt/agentdock:browser-latest # AGENTDOCK_BROWSER_ENABLED=true + +# AGENTS.md 原生自动上下文,默认开启。false 只禁用自动发现,不禁用显式 instructions 文件。 +# 这些变量需由实际 Core 启动环境或容器 environment/env_file 传入。 +# AGENTDOCK_AGENTS_AUTOLOAD=true +# 可选:覆盖 ${AGENTDOCK_HOME}/AGENTS.md 的全局规则来源,必须为 Core 可访问的绝对路径。 +# AGENTDOCK_INSTRUCTIONS_FILE=/path/to/global/AGENTS.md diff --git a/docs/agents-context.md b/docs/agents-context.md new file mode 100644 index 00000000..516b8074 --- /dev/null +++ b/docs/agents-context.md @@ -0,0 +1,81 @@ +# 全局与工作区 AGENTS.md 自动上下文 + +AgentDock Core 原生发现并读取规则文件,不依赖 ACP、Codex、NexusDock 或 Recall。规则文本不会作为命令执行,也不改变文件访问权限。 + +## 加载入口 + +MCP 服务创建时,把全局规则和默认工作区规则加入初始化 instructions,并标明来源、适用目录和“启动快照”。`agentdock_context` 每次调用重新读取文件,返回最新正文和状态,不依赖文件修改时间缓存。 + +```json +{} +``` + +空参数使用当前运行时默认工作目录。操作另一个项目或进入有独立规则的子目录前,传入目标目录: + +```json +{"workdir":"C:\\projects\\example"} +``` + +`workdir` 接受既有 Host 目录、相对路径和 `~/` 路径。选择仅对本次上下文请求有效,不会修改命令工具的默认工作目录、持久化配置或其他客户端的工作区。后续 `exec_command` 等操作仍需传入对应的 `workdir` 或绝对文件路径。 + +规则文件创建、修改或删除后,再调用 `agentdock_context` 即可刷新,无需重启 Core。文件改变不会主动推送或追溯修改客户端已经收到的启动快照,也不会凭空获知用户在自然语言里切换了哪个项目。客户端应在开始项目操作、切换项目或已知规则变化时获取上下文,只在正文尚未提供或需要编辑规则时另行读取文件。 + +## 来源顺序与适用范围 + +1. 全局:显式配置的 `AGENTDOCK_INSTRUCTIONS_FILE`,否则 `${AGENTDOCK_HOME}/AGENTS.md`。默认 `AGENTDOCK_HOME` 为用户目录下的 `.agentdock`。 +2. 工作区根目录的 `AGENTS.md`。 +3. 从该根目录到所选目录之间各级子目录的 `AGENTS.md`,由外向内排列。 + +显式 instructions 文件替代自动全局来源,不与同一份自动全局正文重复合并。全局规则先应用,子目录规则只细化适用目录的项目行为,不得削弱全局安全约束或客户端的更高优先级指令。 + +工作区边界取最近的 `.git` 标记目录,兼容 Git worktree 的 `.git` 文件。没有遇到仓库边界时,若所选目录位于配置的默认目录内,则以默认目录为边界;否则只读取所选目录的规则。发现仓库时只检查祖先的 `.git` 元数据,不读取边界外的祖先 `AGENTS.md`。不递归扫描无关子目录、兄弟项目或全部磁盘。 + +自动发现拒绝规则文件本身的符号链接和其他非普通文件。显式 `AGENTDOCK_INSTRUCTIONS_FILE` 保留原有符号链接解析语义。根目录内的读取使用 `os.Root` 约束路径解析,并校验打开前后的文件身份。同一实际文件通过相同路径或硬链接出现多次时只提供一次正文;不同文件即使文本相同,也保留各自的作用域。 + +## 返回结构与错误处理 + +原有上下文字段不变,新增可选 `instruction_files`: + +```json +{ + "instruction_files": { + "auto_load": true, + "workdir": "/projects/example/src", + "workspace_root": "/projects/example", + "files": [ + { + "scope": "global", + "path": "/home/example/.agentdock/AGENTS.md", + "status": "loaded", + "content": "全局规则正文", + "sha256": "00ee6e16073bc20a849a2b38b9120a3cec0fce3aa573294222e4b78d675c0143", + "size_bytes": 18 + } + ] + } +} +``` + +路径仅为示例。摘要与字节数对应未带换行的示例正文,真实响应按原始文件字节计算。 + +`status` 包括 `loaded`、`not_found`、`empty`、`duplicate`、`skipped` 和 `error`。只有 `loaded` 含可应用的正文。重复条目提供 `duplicate_of`,拒绝或读取失败提供 `reason`。缺失的默认文件不阻止工具工作。显式配置的 instructions 文件仍保留启动配置阶段的严格校验,不能用自动加载掩盖配置错误。 + +单文件最多 64 KiB,单次正文总预算 256 KiB,目录层级最多 64。只接受 UTF-8 文本,支持 UTF-8 BOM 和 CRLF,拒绝 NUL、损坏编码及非普通文件。超限文件整份跳过,不把截断内容当作完整规则。Unix 打开文件时使用非阻塞及禁止叶子符号链接标志,避免检查后被替换成 FIFO 时阻塞。 + +## 配置与兼容性 + +默认启用自动发现。设置 `AGENTDOCK_AGENTS_AUTOLOAD=false` 可禁用自动全局和工作区发现,但不会禁用显式 `AGENTDOCK_INSTRUCTIONS_FILE`。开关与全局路径属于启动配置,修改它们仍需按部署方式重启 Core;仅规则正文改变不需要重启。 + +旧的空参数 `agentdock_context` 调用继续有效。新增 `workdir` 和 `instruction_files` 仅扩展本地工具契约,既有字段及必需字段保持不变。Nexus 私有 `context.local` 不增加字段,使用原有 `rules` 数组携带带来源和作用域的规则正文;不改变共享 protocol 依赖。Nexus 统一入口对任意工作区选择的支持仍由其自身契约决定,不能假定旧版 Nexus 接受本地新增参数。 + +升级 Core 后,缓存工具定义的客户端需要刷新工具定义并重新连接或新建会话。单纯修改源码不会使已运行的旧版本获得此功能。 + +## 开发验证 + +```text +go test ./internal/agentinstructions ./internal/config ./internal/app ./internal/mcp +go vet ./... +go build -o ./bin/agentdock-context.exe ./cmd/agentdock +``` + +内存受限环境为命令进程设置 `GOMAXPROCS=2` 并给 Go 命令添加 `-p 1`,不要为运行测试关闭用户应用或更改系统配置。全局和工作区测试均使用临时目录,不读取测试机真实全局规则。 diff --git a/internal/agentinstructions/instructions.go b/internal/agentinstructions/instructions.go new file mode 100644 index 00000000..b0dda4f9 --- /dev/null +++ b/internal/agentinstructions/instructions.go @@ -0,0 +1,276 @@ +// 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 = 256 << 10 + MaxDirectories = 64 +) + +type Options struct { + Home string + DefaultDir string + Workdir string + GlobalFile string + DisableAutoLoad bool +} + +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 { + AutoLoad bool `json:"auto_load"` + Workdir string `json:"workdir"` + WorkspaceRoot string `json:"workspace_root"` + Files []File `json:"files"` +} + +type candidate struct { + scope, path, root string + explicit bool +} + +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{AutoLoad: !options.DisableAutoLoad, 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.GlobalFile != "" { + if !filepath.IsAbs(options.GlobalFile) { + return snapshot, errors.New("global instruction file must be absolute") + } + candidates = append(candidates, candidate{scope: "global", path: options.GlobalFile, root: filepath.Dir(options.GlobalFile), explicit: true}) + } else if !options.DisableAutoLoad && 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}) + } + if !options.DisableAutoLoad { + 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)} + // An explicitly configured file retains the existing symlink semantics. + // Automatic discovery never follows a leaf symlink into an unrelated file. + if source.explicit { + realPath, err := filepath.EvalSymlinks(source.path) + if err != nil { + return failedFile(file, err), nil + } + source.path, source.root = realPath, filepath.Dir(realPath) + } + 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 +} + +// Text labels provenance and scope rather than promoting repository text into +// unqualified server/operator instructions. File errors remain visible to clients. +func (s Snapshot) Text() string { + var out strings.Builder + for _, file := range s.Files { + switch file.Status { + case "loaded": + fmt.Fprintf(&out, "\n\n### %s guidance\nSource: %q\n", file.Scope, file.Path) + if file.Scope == "workspace" { + fmt.Fprintf(&out, "Scope: %q and its descendants. Refines global guidance; does not override global safety requirements or the client's higher-priority instructions.\n", filepath.Dir(file.Path)) + } + fmt.Fprintf(&out, "SHA-256: %s\n\n%s", file.SHA256, file.Content) + case "error", "skipped": + fmt.Fprintf(&out, "\n\nInstruction file %q was not loaded (%s). Do not claim its rules were applied.", file.Path, file.Reason) + } + } + return strings.TrimSpace(out.String()) +} diff --git a/internal/agentinstructions/instructions_test.go b/internal/agentinstructions/instructions_test.go new file mode 100644 index 00000000..bb847945 --- /dev/null +++ b/internal/agentinstructions/instructions_test.go @@ -0,0 +1,289 @@ +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 || !snapshot.AutoLoad { + t.Fatalf("snapshot = %#v", snapshot) + } + for _, file := range snapshot.Files { + if len(file.SHA256) != 64 || file.SizeBytes == 0 { + t.Fatalf("missing provenance: %#v", file) + } + } + if !strings.Contains(snapshot.Text(), "does not override global safety") { + t.Fatal("workspace scope is not identified") + } +} + +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 TestExplicitGlobalOverrideAndDeduplication(t *testing.T) { + home, root := t.TempDir(), t.TempDir() + writeGuidance(t, home, "unused automatic global") + path := writeGuidance(t, root, "explicit rules") + snapshot := loadGuidance(t, Options{Home: home, DefaultDir: root, Workdir: root, GlobalFile: path}) + if len(snapshot.Files) != 2 || snapshot.Files[0].Scope != "global" || snapshot.Files[1].Status != "duplicate" || snapshot.Files[1].DuplicateOf != path { + t.Fatalf("dedup = %#v", snapshot) + } + if snapshot.Files[1].Content != "" || strings.Count(snapshot.Text(), "explicit rules") != 1 { + t.Fatal("same file injected twice") + } +} + +func TestDisableAutoLoadPreservesExplicitInstructions(t *testing.T) { + home, root := t.TempDir(), t.TempDir() + path := writeGuidance(t, home, "explicit global") + writeGuidance(t, root, "workspace rules") + options := Options{Home: home, DefaultDir: root, Workdir: root, DisableAutoLoad: true} + if snapshot := loadGuidance(t, options); snapshot.AutoLoad || len(snapshot.Files) != 0 { + t.Fatalf("autoload did not disable: %#v", snapshot) + } + options.GlobalFile = path + snapshot := loadGuidance(t, options) + if got := strings.Join(loadedContents(snapshot), "|"); got != "explicit global" { + t.Fatalf("explicit global not preserved: %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 TestAutomaticSymlinkIsNotFollowedButExplicitGlobalIsSupported(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) + } + snapshot = loadGuidance(t, Options{Home: home, DefaultDir: root, Workdir: root, GlobalFile: link}) + if snapshot.Files[0].Status != "loaded" || snapshot.Files[0].Path != link { + t.Fatalf("explicit file semantics changed: %#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) + } + } + if _, err := Load(t.Context(), Options{Workdir: root, GlobalFile: "relative.md"}); err == nil { + t.Fatal("accepted relative explicit file") + } + 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..69f1111b 100644 --- a/internal/app/agentdock_context.go +++ b/internal/app/agentdock_context.go @@ -7,22 +7,27 @@ import ( "strings" "time" + "github.com/uvwt/agentdock/internal/agentinstructions" "github.com/uvwt/agentdock/internal/buildinfo" "github.com/uvwt/agentdock/internal/config" tooltask "github.com/uvwt/agentdock/internal/tool/task" ) func (r *Runtime) AgentDockContext(ctx context.Context) (Result, error) { - return r.agentDockContext(ctx, false) + return r.agentDockContext(ctx, false, "") } // AgentDockLocalContext 仅供 Nexus Bridge 使用。它不读取 Nexus 统一管理的 // Workflow/Recall,避免 fleet 聚合时按节点重复回灌共享上下文。 func (r *Runtime) AgentDockLocalContext(ctx context.Context) (Result, error) { - return r.agentDockContext(ctx, true) + return r.agentDockContext(ctx, true, "") } -func (r *Runtime) agentDockContext(ctx context.Context, nexusLocalOnly bool) (Result, error) { +func (r *Runtime) agentDockContext(ctx context.Context, nexusLocalOnly bool, workdir string) (Result, error) { + instructions, err := r.InstructionFiles(ctx, workdir) + if err != nil { + return nil, err + } skills, skillErr := r.skillCapabilityIndex() commonSkills, commonSkillErr := commonSkillCapabilityIndex() contextResult := capabilityContext{ @@ -37,6 +42,19 @@ func (r *Runtime) agentDockContext(ctx context.Context, nexusLocalOnly bool) (Re "AgentDock 自带工具直接调用;动态 MCP 工具先用 mcp_tool_search 查找、mcp_tool_inspect 读取 schema,再用 mcp_tool_call 执行。", }, } + if r.cfg.InstructionsFile == "" && strings.TrimSpace(r.cfg.Instructions) != "" { + contextResult.Rules = append(contextResult.Rules, "Additional operator instructions:\n"+r.cfg.Instructions) + } + if nexusLocalOnly { + // Keep the shared Bridge context contract unchanged. Device guidance travels + // through its existing rules field, not a node-specific schema extension. + if text := instructions.Text(); text != "" { + contextResult.Rules = append(contextResult.Rules, text) + } + } else { + contextResult.InstructionFiles = &instructions + contextResult.Rules = append(contextResult.Rules, "instruction_files.files 已自动载入规则正文;只应用 status=loaded 的条目,按全局、项目根目录、子目录顺序处理。项目规则不得削弱全局安全要求。操作其他工作区或规则文件已改变时,先调用 agentdock_context 并传入对应 workdir 刷新;该参数不会修改命令的默认工作目录。") + } if !nexusLocalOnly { // runtime 只保留模型操作主机所需的稳定环境事实;Nexus Bridge 已通过 Hello 持有这些节点事实, // 私有 context.local 不重复传输,避免两个来源长期漂移。 @@ -99,11 +117,16 @@ 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) +func (r *Runtime) agentDockContextTool(ctx context.Context, args map[string]any) (Result, error) { + var request contextRequest + if err := decodeToolInput("agentdock_context", args, &request); err != nil { + return nil, err + } + return r.agentDockContext(ctx, false, request.Workdir) } type capabilityContext struct { + InstructionFiles *agentinstructions.Snapshot `json:"instruction_files,omitempty"` Runtime *capabilityRuntimeContext `json:"runtime,omitempty"` Skills []capabilitySkillItem `json:"skills"` CommonSkills *capabilityCommonSkillIndex `json:"common_skills,omitempty"` diff --git a/internal/app/contract_drift_test.go b/internal/app/contract_drift_test.go index 7e84a908..49bb427c 100644 --- a/internal/app/contract_drift_test.go +++ b/internal/app/contract_drift_test.go @@ -69,6 +69,7 @@ func TestTypedToolRequestFieldsMatchPublishedSchemas(t *testing.T) { exact bool allowExtra []string }{ + {name: "agentdock_context", request: contextRequest{}, 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/instruction_files.go b/internal/app/instruction_files.go new file mode 100644 index 00000000..be2ea458 --- /dev/null +++ b/internal/app/instruction_files.go @@ -0,0 +1,31 @@ +package app + +import ( + "context" + "os" + + "github.com/uvwt/agentdock/internal/agentinstructions" +) + +// InstructionFiles selects guidance for this request only. It must not change +// Workspace.DefaultCWD: one Runtime can serve multiple independent clients. +func (r *Runtime) InstructionFiles(ctx context.Context, workdir string) (agentinstructions.Snapshot, error) { + if err := ctx.Err(); err != nil { + return agentinstructions.Snapshot{}, err + } + resolved, err := r.ws.ResolveExisting(workdir) + if err != nil { + return agentinstructions.Snapshot{}, toolErrorDetails("INVALID_ARGUMENT", "instruction 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 agentinstructions.Snapshot{}, toolErrorDetails("INVALID_ARGUMENT", "instruction workdir must be a directory", "validation", map[string]any{"workdir": workdir}) + } + return agentinstructions.Load(ctx, agentinstructions.Options{ + Home: r.cfg.AgentDockHome, + DefaultDir: r.ws.Root(), + Workdir: resolved.Abs, + GlobalFile: r.cfg.InstructionsFile, + DisableAutoLoad: r.cfg.AgentsAutoLoadDisabled, + }) +} diff --git a/internal/app/instruction_files_test.go b/internal/app/instruction_files_test.go new file mode 100644 index 00000000..10f3fad4 --- /dev/null +++ b/internal/app/instruction_files_test.go @@ -0,0 +1,241 @@ +package app + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/uvwt/agentdock/internal/agentinstructions" + "github.com/uvwt/agentdock/internal/config" +) + +func newInstructionRuntime(t *testing.T, configure func(*config.Config)) *Runtime { + t.Helper() + cfg := config.Config{AgentDockHome: t.TempDir(), AgentDockDefaultDir: t.TempDir()} + if configure != nil { + configure(&cfg) + } + 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 +} + +func writeInstructionFixture(t *testing.T, dir, content string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "AGENTS.md") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func instructionContext(t *testing.T, rt *Runtime, args map[string]any) capabilityContext { + t.Helper() + result, err := rt.Call(t.Context(), "agentdock_context", args) + if err != nil { + t.Fatal(err) + } + assertToolResultMatchestestOutputSchema(t, "agentdock_context", result) + var got capabilityContext + if err := remarshal(result, &got); err != nil { + t.Fatal(err) + } + if got.InstructionFiles == nil { + t.Fatal("instruction_files missing") + } + return got +} + +func instructionBodies(snapshot *agentinstructions.Snapshot) string { + var contents []string + for _, file := range snapshot.Files { + if file.Status == "loaded" { + contents = append(contents, file.Content) + } + } + return strings.Join(contents, "|") +} + +func TestInstructionContextLoadsGlobalAndWorkspaceWithoutACP(t *testing.T) { + rt := newInstructionRuntime(t, nil) + writeInstructionFixture(t, rt.cfg.AgentDockHome, "global rule") + writeInstructionFixture(t, rt.ws.Root(), "project rule") + got := instructionContext(t, rt, nil) + if body := instructionBodies(got.InstructionFiles); body != "global rule|project rule" { + t.Fatalf("guidance=%q", body) + } + if got.ACP != nil { + t.Fatal("autoload enabled ACP") + } + if !strings.Contains(strings.Join(got.Rules, "\n"), "status=loaded") { + t.Fatal("context does not explain which files to apply") + } +} + +func TestInstructionContextWorkspaceSelectionIsRequestLocal(t *testing.T) { + rt := newInstructionRuntime(t, nil) + root := rt.ws.Root() + writeInstructionFixture(t, rt.cfg.AgentDockHome, "global") + writeInstructionFixture(t, root, "default") + projectA, projectB := t.TempDir(), t.TempDir() + writeInstructionFixture(t, projectA, "project-a") + writeInstructionFixture(t, projectB, "project-b") + for _, test := range []struct{ workdir, want string }{ + {projectA, "global|project-a"}, {projectB, "global|project-b"}, {"", "global|default"}, + } { + got := instructionContext(t, rt, map[string]any{"workdir": test.workdir}) + if body := instructionBodies(got.InstructionFiles); body != test.want { + t.Fatalf("workdir=%q: %q", test.workdir, body) + } + if rt.ws.DefaultCWD() != root { + t.Fatal("request changed process-wide working directory") + } + } + writeInstructionFixture(t, filepath.Join(root, "subdir"), "child") + got := instructionContext(t, rt, map[string]any{"workdir": "subdir"}) + if body := instructionBodies(got.InstructionFiles); body != "global|default|child" { + t.Fatalf("relative selection=%q", body) + } +} + +func TestInstructionContextConcurrentWorkspacesRemainIsolated(t *testing.T) { + rt := newInstructionRuntime(t, nil) + root := rt.ws.DefaultCWD() + writeInstructionFixture(t, rt.cfg.AgentDockHome, "global") + projects := []string{t.TempDir(), t.TempDir()} + writeInstructionFixture(t, projects[0], "first") + writeInstructionFixture(t, projects[1], "second") + var wg sync.WaitGroup + for index := range 16 { + wg.Add(1) + go func(index int) { + defer wg.Done() + result, err := rt.Call(t.Context(), "agentdock_context", map[string]any{"workdir": projects[index%2]}) + if err != nil { + t.Error(err) + return + } + var got capabilityContext + if err := remarshal(result, &got); err != nil { + t.Error(err) + return + } + want := []string{"global|first", "global|second"}[index%2] + if got.InstructionFiles == nil || instructionBodies(got.InstructionFiles) != want { + t.Errorf("workspace context leaked across requests: %#v", got.InstructionFiles) + } + }(index) + } + wg.Wait() + if rt.ws.DefaultCWD() != root { + t.Fatal("concurrent selection changed default") + } +} + +func TestInstructionContextRefreshesExplicitFileInsteadOfStartupCopy(t *testing.T) { + explicitDir := t.TempDir() + path := writeInstructionFixture(t, explicitDir, "old-global-unique") + rt := newInstructionRuntime(t, func(cfg *config.Config) { cfg.InstructionsFile = path }) + writeInstructionFixture(t, rt.cfg.AgentDockHome, "unused automatic global") + writeInstructionFixture(t, rt.ws.Root(), "workspace") + before := instructionContext(t, rt, nil) + writeInstructionFixture(t, explicitDir, "new-global-unique") + after := instructionContext(t, rt, nil) + if body := instructionBodies(after.InstructionFiles); body != "new-global-unique|workspace" { + t.Fatalf("refresh=%q", body) + } + if before.InstructionFiles.Files[0].SHA256 == after.InstructionFiles.Files[0].SHA256 { + t.Fatal("explicit hash did not refresh") + } + if strings.Contains(strings.Join(after.Rules, "\n"), "old-global-unique") { + t.Fatal("startup instructions leaked after refresh") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + removed := instructionContext(t, rt, nil) + if removed.InstructionFiles.Files[0].Status != "not_found" { + t.Fatal("removed explicit file remains loaded") + } +} + +func TestInstructionContextMissingSkippedAndDisabledStatesMatchSchema(t *testing.T) { + rt := newInstructionRuntime(t, nil) + missing := instructionContext(t, rt, nil) + if len(missing.InstructionFiles.Files) != 2 || instructionBodies(missing.InstructionFiles) != "" { + t.Fatalf("missing=%#v", missing.InstructionFiles) + } + writeInstructionFixture(t, rt.ws.Root(), strings.Repeat("x", agentinstructions.MaxFileBytes+1)) + skipped := instructionContext(t, rt, nil) + if skipped.InstructionFiles.Files[1].Reason != "file_size_limit" { + t.Fatal("oversized guidance not reported") + } + disabled := newInstructionRuntime(t, func(cfg *config.Config) { cfg.AgentsAutoLoadDisabled = true }) + writeInstructionFixture(t, disabled.cfg.AgentDockHome, "not loaded") + writeInstructionFixture(t, disabled.ws.Root(), "not loaded") + got := instructionContext(t, disabled, nil) + if got.InstructionFiles.AutoLoad || len(got.InstructionFiles.Files) != 0 { + t.Fatal("disabled autoload read files") + } +} + +func TestInstructionContextBridgePreservesSharedShape(t *testing.T) { + rt := newInstructionRuntime(t, nil) + writeInstructionFixture(t, rt.cfg.AgentDockHome, "bridge-global-marker") + writeInstructionFixture(t, rt.ws.Root(), "bridge-project-marker") + result, err := rt.AgentDockLocalContext(t.Context()) + if err != nil { + t.Fatal(err) + } + if _, exists := result["instruction_files"]; exists { + t.Fatal("local-only extension leaked into shared Bridge context") + } + var got capabilityContext + if err := remarshal(result, &got); err != nil { + t.Fatal(err) + } + rules := strings.Join(got.Rules, "\n") + for _, marker := range []string{"bridge-global-marker", "bridge-project-marker"} { + if strings.Count(rules, marker) != 1 { + t.Fatalf("Bridge missing or repeated %s", marker) + } + } +} + +func TestInstructionContextRejectsInvalidWorkdirAndUnknownFields(t *testing.T) { + rt := newInstructionRuntime(t, nil) + file := writeInstructionFixture(t, rt.ws.Root(), "rules") + for _, args := range []map[string]any{ + {"workdir": 42}, {"workdir": nil}, {"workdir": file}, + {"workdir": filepath.Join(rt.ws.Root(), "missing")}, + {"workdir": "bad\x00path"}, {"unknown": true}, {"workdir": strings.Repeat("x", 4097)}, + } { + _, err := rt.Call(t.Context(), "agentdock_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, "agentdock_context", nil); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation=%v", err) + } +} diff --git a/internal/app/mcp_contract_test.go b/internal/app/mcp_contract_test.go index 55df6eed..dee156e6 100644 --- a/internal/app/mcp_contract_test.go +++ b/internal/app/mcp_contract_test.go @@ -1,6 +1,7 @@ package app import ( + "maps" "reflect" "testing" @@ -24,7 +25,14 @@ 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 name == mcpcontract.ToolAgentDockContext { + // Standalone AgentDock adds only optional local context fields. Compare + // every remaining field against the unchanged shared protocol contract. + actualInput = withoutLocalContextProperty(t, actualInput, "workdir") + actualOutput = withoutLocalContextProperty(t, actualOutput, "instruction_files") + } + if !reflect.DeepEqual(actualInput, wantInput) { t.Fatalf("%s input schema drifted from shared contract", name) } var wantOutput map[string]any @@ -33,7 +41,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) } @@ -51,3 +59,21 @@ func TestCanonicalToolDefinitionsMatchSharedContract(t *testing.T) { } } } + +func withoutLocalContextProperty(t *testing.T, schema map[string]any, name string) map[string]any { + t.Helper() + copy := maps.Clone(schema) + properties := maps.Clone(schema["properties"].(map[string]any)) + if properties[name] == nil { + t.Fatalf("local context extension %q missing", name) + } + requiredFields, _ := schema["required"].([]string) + for _, required := range requiredFields { + if required == name { + t.Fatalf("local extension %q must remain optional", name) + } + } + delete(properties, name) + copy["properties"] = properties + return copy +} diff --git a/internal/app/specs_context.go b/internal/app/specs_context.go index 8a238c83..01d1ebb5 100644 --- a/internal/app/specs_context.go +++ b/internal/app/specs_context.go @@ -1,9 +1,70 @@ package app +import ( + "maps" + + "github.com/uvwt/agentdock/internal/agentinstructions" + "github.com/uvwt/agentdock/internal/config" +) + +type contextRequest struct { + Workdir string `json:"workdir,omitempty"` +} + 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.", + Name: "agentdock_context", Contract: contextToolContract, Title: "AgentDock context", + Description: "Return structured AgentDock bootstrap context including capabilities, integrations, rules, and automatically loaded global/workspace AGENTS.md content. Call before project operations; pass workdir when selecting another workspace or refreshing changed rules. Selection is request-local and never changes command defaults.", Handler: ctxToolHandler((*Runtime).agentDockContextTool), }} } + +// The standalone entrypoint adds optional local fields without changing the +// shared Nexus Bridge contract. All existing canonical fields remain identical. +func contextToolContract(name string, cfg config.Config) (ToolContract, bool) { + contract, ok := canonicalToolContract(name, cfg) + if !ok { + return ToolContract{}, false + } + contract.InputSchema = maps.Clone(contract.InputSchema) + input := maps.Clone(contract.InputSchema["properties"].(map[string]any)) + input["workdir"] = map[string]any{ + "type": "string", "maxLength": 4096, + "description": "Existing host workspace directory. Omit or use an empty string for the current default; relative and ~/ paths use Host resolution. Does not change any session or command working directory.", + } + contract.InputSchema["properties"] = input + contract.OutputSchema = maps.Clone(contract.OutputSchema) + output := maps.Clone(contract.OutputSchema["properties"].(map[string]any)) + output["instruction_files"] = instructionFilesSchema() + contract.OutputSchema["properties"] = output + return contract, true +} + +func instructionFilesSchema() map[string]any { + return map[string]any{ + "type": "object", "additionalProperties": false, + "required": []string{"auto_load", "workdir", "workspace_root", "files"}, + "properties": map[string]any{ + "auto_load": map[string]any{"type": "boolean"}, + "workdir": map[string]any{"type": "string"}, + "workspace_root": map[string]any{"type": "string"}, + "files": map[string]any{ + "type": "array", "maxItems": agentinstructions.MaxDirectories + 1, + "items": map[string]any{ + "type": "object", "additionalProperties": false, + "required": []string{"scope", "path", "status"}, + "properties": map[string]any{ + "scope": map[string]any{"type": "string", "enum": []string{"global", "workspace"}}, + "path": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string", "enum": []string{"loaded", "not_found", "empty", "duplicate", "skipped", "error"}}, + "content": map[string]any{"type": "string", "maxLength": agentinstructions.MaxFileBytes}, + "sha256": map[string]any{"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "size_bytes": map[string]any{"type": "integer", "minimum": 0}, + "reason": map[string]any{"type": "string"}, + "duplicate_of": map[string]any{"type": "string"}, + }, + }, + }, + }, + } +} diff --git a/internal/config/agents_autoload_test.go b/internal/config/agents_autoload_test.go new file mode 100644 index 00000000..4d5f4658 --- /dev/null +++ b/internal/config/agents_autoload_test.go @@ -0,0 +1,36 @@ +package config + +import "testing" + +func TestAgentsAutoLoadEnvironment(t *testing.T) { + for _, test := range []struct { + value string + disabled bool + invalid bool + }{ + {value: ""}, + {value: "true"}, + {value: "1"}, + {value: "false", disabled: true}, + {value: "0", disabled: true}, + {value: "unexpected", invalid: true}, + } { + t.Run("value="+test.value, func(t *testing.T) { + t.Setenv("AGENTDOCK_AGENTS_AUTOLOAD", test.value) + t.Setenv("AGENTDOCK_ACP_ENABLED", "false") + cfg, err := FromEnv() + if test.invalid { + if err == nil { + t.Fatal("accepted invalid autoload boolean") + } + return + } + if err != nil { + t.Fatal(err) + } + if cfg.AgentsAutoLoadDisabled != test.disabled { + t.Fatalf("disabled=%v, want %v", cfg.AgentsAutoLoadDisabled, test.disabled) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 197bda44..82f4fcc0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,6 +57,7 @@ type Config struct { TrustedProxyCIDRs []string InstructionsFile string Instructions string + AgentsAutoLoadDisabled bool } // ACPProfile 表示一个可独立运行、独立持久化会话的 ACP 实例。 @@ -72,6 +73,10 @@ type ACPProfile struct { } func FromEnv() (Config, error) { + agentsAutoLoad, err := getenvBool("AGENTDOCK_AGENTS_AUTOLOAD", true) + if err != nil { + return Config{}, err + } port, err := getenvInt("AGENTDOCK_PORT", 8765) if err != nil { return Config{}, err @@ -163,6 +168,7 @@ func FromEnv() (Config, error) { Stdio: stdio, TrustedProxyCIDRs: splitCommaSeparated(os.Getenv("AGENTDOCK_TRUSTED_PROXY_CIDRS")), InstructionsFile: strings.TrimSpace(os.Getenv("AGENTDOCK_INSTRUCTIONS_FILE")), + AgentsAutoLoadDisabled: !agentsAutoLoad, }, nil } diff --git a/internal/mcp/bootstrap_instructions.go b/internal/mcp/bootstrap_instructions.go new file mode 100644 index 00000000..0d3ccc48 --- /dev/null +++ b/internal/mcp/bootstrap_instructions.go @@ -0,0 +1,30 @@ +package mcp + +import ( + "context" + + "github.com/uvwt/agentdock/internal/app" + "github.com/uvwt/agentdock/internal/config" +) + +func initialServerInstructions(runtime *app.Runtime, cfg config.Config) string { + custom := cfg.Instructions + if runtime != nil && cfg.InstructionsFile != "" { + // The same explicitly configured file is loaded below with provenance. + // Do not duplicate the startup copy or later re-expose stale file content. + custom = "" + } + instructions := serverInstructions(cfg.NexusEndpoint != "", custom) + instructions += "\n\nBefore operating on a project, call agentdock_context with its workdir to receive current global and workspace AGENTS.md guidance. Apply only loaded files in their reported order. Workspace guidance must not weaken global safety requirements or the client's higher-priority instructions. Refresh after workspace/rule changes. workdir selection does not change command defaults." + if runtime == nil { + return instructions + } + files, err := runtime.InstructionFiles(context.Background(), "") + if err != nil { + return instructions + "\n\nAutomatic AGENTS.md startup loading failed. Call agentdock_context to diagnose before project operations; do not assume rules were loaded." + } + if text := files.Text(); text != "" { + instructions += "\n\nAutomatically loaded instruction files (startup snapshot, scoped to the reported directories; refresh with agentdock_context):\n" + text + } + return instructions +} diff --git a/internal/mcp/bootstrap_instructions_test.go b/internal/mcp/bootstrap_instructions_test.go new file mode 100644 index 00000000..a62d4a71 --- /dev/null +++ b/internal/mcp/bootstrap_instructions_test.go @@ -0,0 +1,164 @@ +package mcp + +import ( + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/uvwt/agentdock/internal/agentinstructions" + "github.com/uvwt/agentdock/internal/app" + "github.com/uvwt/agentdock/internal/config" +) + +func writeBootstrapInstructions(t *testing.T, dir, text string) string { + t.Helper() + path := filepath.Join(dir, "AGENTS.md") + if err := os.WriteFile(path, []byte(text), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func connectInstructionClient(t *testing.T, server *Server) *mcpsdk.ClientSession { + t.Helper() + 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: "instruction-test", Version: "1.0.0"}, nil) + // T.Context is canceled before Cleanup; this session must remain alive + // until Cleanup closes it and observes the server's orderly shutdown. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + session, err := client.Connect(ctx, &mcpsdk.IOTransport{Reader: clientInput, Writer: clientOutput}, nil) + if err != nil { + cancel() + _ = clientInput.Close() + _ = clientOutput.Close() + _ = serverInput.Close() + _ = serverOutput.Close() + t.Fatal(err) + } + t.Cleanup(func() { + defer cancel() + defer clientInput.Close() + defer clientOutput.Close() + defer serverInput.Close() + defer serverOutput.Close() + if err := session.Close(); err != nil { + t.Error(err) + } + select { + case err := <-serverDone: + if err != nil { + t.Error(err) + } + case <-time.After(5 * time.Second): + t.Error("instruction test server did not stop") + } + }) + return session +} + +func TestInstructionBootstrapAndLiveContextThroughMCP(t *testing.T) { + for _, explicit := range []bool{false, true} { + name := "automatic" + if explicit { + name = "explicit global override" + } + t.Run(name, func(t *testing.T) { + cfg := config.Config{AgentDockHome: t.TempDir(), AgentDockDefaultDir: t.TempDir()} + global := writeBootstrapInstructions(t, cfg.AgentDockHome, "global-before-marker") + writeBootstrapInstructions(t, cfg.AgentDockDefaultDir, "workspace-before-marker") + if explicit { + cfg.InstructionsFile = global + } + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + rt, err := app.NewRuntime(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = rt.Close() }) + session := connectInstructionClient(t, NewServer(rt, cfg)) + initial := session.InitializeResult().Instructions + for _, marker := range []string{"global-before-marker", "workspace-before-marker"} { + if strings.Count(initial, marker) != 1 { + t.Fatalf("initial instructions missing/repeated %q: %s", marker, initial) + } + } + if strings.Index(initial, "global-before-marker") > strings.Index(initial, "workspace-before-marker") { + t.Fatal("global rules must precede workspace rules") + } + writeBootstrapInstructions(t, cfg.AgentDockHome, "global-after-marker") + selected := t.TempDir() + writeBootstrapInstructions(t, selected, "selected-workspace-marker") + result, err := session.CallTool(t.Context(), &mcpsdk.CallToolParams{Name: "agentdock_context", Arguments: map[string]any{"workdir": selected}}) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("MCP context failed: %#v", result) + } + encoded, err := json.Marshal(result.StructuredContent) + if err != nil { + t.Fatal(err) + } + var got struct { + Instructions agentinstructions.Snapshot `json:"instruction_files"` + } + if err := json.Unmarshal(encoded, &got); err != nil { + t.Fatal(err) + } + if len(got.Instructions.Files) != 2 || got.Instructions.Files[0].Content != "global-after-marker" || got.Instructions.Files[1].Content != "selected-workspace-marker" { + t.Fatalf("live MCP instructions=%s", encoded) + } + for _, old := range []string{"global-before-marker", "workspace-before-marker"} { + if strings.Contains(string(encoded), old) { + t.Fatalf("live response leaked stale text: %s", old) + } + } + if rt.Workspace().DefaultCWD() == selected { + t.Fatal("MCP request changed workspace default") + } + }) + } +} + +func TestInstructionBootstrapOptOutAndFileErrors(t *testing.T) { + cfg := config.Config{AgentDockHome: t.TempDir(), AgentDockDefaultDir: t.TempDir(), AgentsAutoLoadDisabled: true} + writeBootstrapInstructions(t, cfg.AgentDockHome, "disabled-global-marker") + writeBootstrapInstructions(t, cfg.AgentDockDefaultDir, "disabled-workspace-marker") + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + rt, err := app.NewRuntime(cfg) + if err != nil { + t.Fatal(err) + } + defer rt.Close() + text := initialServerInstructions(rt, cfg) + if strings.Contains(text, "disabled-global-marker") || strings.Contains(text, "disabled-workspace-marker") { + t.Fatal("opt-out still injected automatic files") + } + cfg.AgentsAutoLoadDisabled = false + writeBootstrapInstructions(t, cfg.AgentDockHome, strings.Repeat("x", agentinstructions.MaxFileBytes+1)) + enabled, err := app.NewRuntime(cfg) + if err != nil { + t.Fatal(err) + } + defer enabled.Close() + text = initialServerInstructions(enabled, cfg) + if !strings.Contains(text, "file_size_limit") || !strings.Contains(text, "Do not claim its rules were applied") { + t.Fatal("startup file failure is not disclosed") + } + if strings.Contains(text, strings.Repeat("x", 100)) { + t.Fatal("oversized startup body leaked") + } +} diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go index df479b64..ae8e2b0e 100644 --- a/internal/mcp/registry_test.go +++ b/internal/mcp/registry_test.go @@ -104,15 +104,18 @@ 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) + if len(inputProps) != 1 || inputProps["workdir"] == nil { + t.Fatalf("agentdock_context should expose only the optional request-local workdir selector: %#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) if !ok { t.Fatal("agentdock_context output schema properties missing") } - for _, name := range []string{"runtime", "skills", "dynamic_mcp", "acp", "workflow_templates", "recall", "rules", "warnings"} { + for _, name := range []string{"runtime", "skills", "dynamic_mcp", "acp", "workflow_templates", "recall", "rules", "warnings", "instruction_files"} { if _, ok := outputProps[name]; !ok { t.Fatalf("agentdock_context output schema missing %q: %#v", name, outputProps) } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b52a62f4..570ec2da 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: initialServerInstructions(runtime, cfg), } server.sdk = mcpsdk.NewServer( &mcpsdk.Implementation{Name: config.ServerName, Version: buildinfo.Version}, From 5b145e083df72864f9c60d231a4b0c7b544200cd Mon Sep 17 00:00:00 2001 From: x x Date: Tue, 22 Sep 2026 10:58:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(context):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E4=B8=8A=E4=B8=8B=E6=96=87=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 - .../references/configuration.md | 3 +- docs/agents-context.md | 85 ++---- go.mod | 2 +- go.sum | 4 +- internal/agentinstructions/instructions.go | 66 +---- .../agentinstructions/instructions_test.go | 42 +-- internal/app/agentdock_context.go | 34 +-- internal/app/agentdock_context_test.go | 2 +- internal/app/common_skills.go | 52 +--- internal/app/common_skills_test.go | 47 ++- internal/app/contract_drift_test.go | 3 +- internal/app/filesystem_skills.go | 137 +++++++++ internal/app/instruction_files.go | 31 -- internal/app/instruction_files_test.go | 241 --------------- internal/app/mcp_contract_test.go | 25 -- internal/app/output_contract_coverage_test.go | 1 + internal/app/specs_context.go | 78 +---- internal/app/workspace_context.go | 96 ++++++ internal/app/workspace_context_test.go | 278 ++++++++++++++++++ internal/config/agents_autoload_test.go | 36 --- internal/config/config.go | 54 ---- internal/config/instructions_test.go | 148 ---------- internal/mcp/apps_test.go | 8 +- internal/mcp/bootstrap_instructions.go | 30 -- internal/mcp/bootstrap_instructions_test.go | 164 ----------- internal/mcp/instructions.go | 17 +- internal/mcp/registry_test.go | 34 ++- internal/mcp/server.go | 2 +- internal/mcp/stdio_test.go | 93 ++++-- 30 files changed, 742 insertions(+), 1077 deletions(-) create mode 100644 internal/app/filesystem_skills.go delete mode 100644 internal/app/instruction_files.go delete mode 100644 internal/app/instruction_files_test.go create mode 100644 internal/app/workspace_context.go create mode 100644 internal/app/workspace_context_test.go delete mode 100644 internal/config/agents_autoload_test.go delete mode 100644 internal/config/instructions_test.go delete mode 100644 internal/mcp/bootstrap_instructions.go delete mode 100644 internal/mcp/bootstrap_instructions_test.go diff --git a/.env.example b/.env.example index fd2e9d56..67369c76 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,3 @@ TUNNEL_TOKEN=replace-with-cloudflare-tunnel-token # 可选:浏览器自动化镜像。 # AGENTDOCK_IMAGE=ghcr.io/uvwt/agentdock:browser-latest # AGENTDOCK_BROWSER_ENABLED=true - -# AGENTS.md 原生自动上下文,默认开启。false 只禁用自动发现,不禁用显式 instructions 文件。 -# 这些变量需由实际 Core 启动环境或容器 environment/env_file 传入。 -# AGENTDOCK_AGENTS_AUTOLOAD=true -# 可选:覆盖 ${AGENTDOCK_HOME}/AGENTS.md 的全局规则来源,必须为 Core 可访问的绝对路径。 -# AGENTDOCK_INSTRUCTIONS_FILE=/path/to/global/AGENTS.md 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 index 516b8074..3f5f0fda 100644 --- a/docs/agents-context.md +++ b/docs/agents-context.md @@ -1,81 +1,42 @@ -# 全局与工作区 AGENTS.md 自动上下文 +# Workspace 上下文 -AgentDock Core 原生发现并读取规则文件,不依赖 ACP、Codex、NexusDock 或 Recall。规则文本不会作为命令执行,也不改变文件访问权限。 +AgentDock 把设备级能力总览与项目级规则分开处理:`agentdock_context` 只返回 Runtime、Skill、动态 MCP、Workflow、Recall、ACP 和稳定操作规则;项目规则与工作区 Skill 由独立的 `workspace_context` 按请求读取。 -## 加载入口 +## 规则文件 -MCP 服务创建时,把全局规则和默认工作区规则加入初始化 instructions,并标明来源、适用目录和“启动快照”。`agentdock_context` 每次调用重新读取文件,返回最新正文和状态,不依赖文件修改时间缓存。 +AgentDock 只识别两类 `AGENTS.md`: -```json -{} -``` - -空参数使用当前运行时默认工作目录。操作另一个项目或进入有独立规则的子目录前,传入目标目录: - -```json -{"workdir":"C:\\projects\\example"} -``` - -`workdir` 接受既有 Host 目录、相对路径和 `~/` 路径。选择仅对本次上下文请求有效,不会修改命令工具的默认工作目录、持久化配置或其他客户端的工作区。后续 `exec_command` 等操作仍需传入对应的 `workdir` 或绝对文件路径。 +1. 全局规则固定为 `~/.agentdock/AGENTS.md`;不提供自定义全局规则路径。 +2. 工作区规则从 workspace root 的 `AGENTS.md` 开始,到本次 `workdir` 之间逐级继承子目录 `AGENTS.md`。 -规则文件创建、修改或删除后,再调用 `agentdock_context` 即可刷新,无需重启 Core。文件改变不会主动推送或追溯修改客户端已经收到的启动快照,也不会凭空获知用户在自然语言里切换了哪个项目。客户端应在开始项目操作、切换项目或已知规则变化时获取上下文,只在正文尚未提供或需要编辑规则时另行读取文件。 +workspace root 优先使用离 `workdir` 最近的 Git / worktree 边界;在 AgentDock 默认工作目录内部且没有更近 Git 边界时,以默认工作目录为边界;两者都不存在时只检查当前 `workdir`。 -## 来源顺序与适用范围 +`workspace_context` 每次调用都重新读取文件,不使用 mtime 缓存,也不会修改进程 cwd、AgentDock 默认工作目录或后续命令的默认目录。开始操作具体项目、切换工作区或规则可能变化时应重新调用。 -1. 全局:显式配置的 `AGENTDOCK_INSTRUCTIONS_FILE`,否则 `${AGENTDOCK_HOME}/AGENTS.md`。默认 `AGENTDOCK_HOME` 为用户目录下的 `.agentdock`。 -2. 工作区根目录的 `AGENTS.md`。 -3. 从该根目录到所选目录之间各级子目录的 `AGENTS.md`,由外向内排列。 +## workspace_context -显式 instructions 文件替代自动全局来源,不与同一份自动全局正文重复合并。全局规则先应用,子目录规则只细化适用目录的项目行为,不得削弱全局安全约束或客户端的更高优先级指令。 - -工作区边界取最近的 `.git` 标记目录,兼容 Git worktree 的 `.git` 文件。没有遇到仓库边界时,若所选目录位于配置的默认目录内,则以默认目录为边界;否则只读取所选目录的规则。发现仓库时只检查祖先的 `.git` 元数据,不读取边界外的祖先 `AGENTS.md`。不递归扫描无关子目录、兄弟项目或全部磁盘。 - -自动发现拒绝规则文件本身的符号链接和其他非普通文件。显式 `AGENTDOCK_INSTRUCTIONS_FILE` 保留原有符号链接解析语义。根目录内的读取使用 `os.Root` 约束路径解析,并校验打开前后的文件身份。同一实际文件通过相同路径或硬链接出现多次时只提供一次正文;不同文件即使文本相同,也保留各自的作用域。 - -## 返回结构与错误处理 - -原有上下文字段不变,新增可选 `instruction_files`: +直接连接 AgentDock 时输入只有一个可选字段: ```json -{ - "instruction_files": { - "auto_load": true, - "workdir": "/projects/example/src", - "workspace_root": "/projects/example", - "files": [ - { - "scope": "global", - "path": "/home/example/.agentdock/AGENTS.md", - "status": "loaded", - "content": "全局规则正文", - "sha256": "00ee6e16073bc20a849a2b38b9120a3cec0fce3aa573294222e4b78d675c0143", - "size_bytes": 18 - } - ] - } -} +{"workdir": "/absolute/or/host-resolvable/workspace"} ``` -路径仅为示例。摘要与字节数对应未带换行的示例正文,真实响应按原始文件字节计算。 +省略 `workdir` 使用 AgentDock 当前默认工作目录。返回结构包含: -`status` 包括 `loaded`、`not_found`、`empty`、`duplicate`、`skipped` 和 `error`。只有 `loaded` 含可应用的正文。重复条目提供 `duplicate_of`,拒绝或读取失败提供 `reason`。缺失的默认文件不阻止工具工作。显式配置的 instructions 文件仍保留启动配置阶段的严格校验,不能用自动加载掩盖配置错误。 +- `workdir`:本次实际解析的工作目录; +- `workspace_root`:规则继承和 workspace Skill 扫描的根目录; +- `instructions`:按全局 → workspace root → 子目录顺序排列的规则文件状态与完整正文; +- `workspace_skills`:`/.agents/skills/*/SKILL.md` 的 name / description / file 索引,不返回 Skill 正文; +- `warnings`:无法生成某个 best-effort 索引时的安全提示。 -单文件最多 64 KiB,单次正文总预算 256 KiB,目录层级最多 64。只接受 UTF-8 文本,支持 UTF-8 BOM 和 CRLF,拒绝 NUL、损坏编码及非普通文件。超限文件整份跳过,不把截断内容当作完整规则。Unix 打开文件时使用非阻塞及禁止叶子符号链接标志,避免检查后被替换成 FIFO 时阻塞。 +`instructions` 中每项包含 `scope`、`path`、`status`,并按状态提供 `content`、`sha256`、`size_bytes`、`reason` 或 `duplicate_of`。状态可能是 `loaded`、`not_found`、`empty`、`duplicate`、`skipped`、`error`。只有 `loaded` 的正文是完整有效规则;AgentDock 不会截断正文后伪装成成功加载。 -## 配置与兼容性 +## 安全与预算 -默认启用自动发现。设置 `AGENTDOCK_AGENTS_AUTOLOAD=false` 可禁用自动全局和工作区发现,但不会禁用显式 `AGENTDOCK_INSTRUCTIONS_FILE`。开关与全局路径属于启动配置,修改它们仍需按部署方式重启 Core;仅规则正文改变不需要重启。 +每个 `AGENTS.md` 最多 64 KiB,单次 `workspace_context` 的规则正文总预算为 128 KiB。超过总预算的后续文件保留 metadata 并标记 `skipped`。读取要求 UTF-8 文本且拒绝 NUL、leaf symlink、FIFO 和其他非普通文件;使用 `os.Root`、文件身份校验和物理文件去重限制竞态与路径逃逸。 -旧的空参数 `agentdock_context` 调用继续有效。新增 `workdir` 和 `instruction_files` 仅扩展本地工具契约,既有字段及必需字段保持不变。Nexus 私有 `context.local` 不增加字段,使用原有 `rules` 数组携带带来源和作用域的规则正文;不改变共享 protocol 依赖。Nexus 统一入口对任意工作区选择的支持仍由其自身契约决定,不能假定旧版 Nexus 接受本地新增参数。 +Workspace Skill 固定扫描 `/.agents/skills//SKILL.md`,只建立 metadata 索引。需要执行 Skill 时再用 `read_file` 读取返回的 `file`。选择同名能力时优先级为 workspace Skill → AgentDock Skill → `~/.agents/skills` common Skill。 -升级 Core 后,缓存工具定义的客户端需要刷新工具定义并重新连接或新建会话。单纯修改源码不会使已运行的旧版本获得此功能。 - -## 开发验证 - -```text -go test ./internal/agentinstructions ./internal/config ./internal/app ./internal/mcp -go vet ./... -go build -o ./bin/agentdock-context.exe ./cmd/agentdock -``` +## MCP 初始化 -内存受限环境为命令进程设置 `GOMAXPROCS=2` 并给 Go 命令添加 `-p 1`,不要为运行测试关闭用户应用或更改系统配置。全局和工作区测试均使用临时目录,不读取测试机真实全局规则。 +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 index b0dda4f9..a57faa5e 100644 --- a/internal/agentinstructions/instructions.go +++ b/internal/agentinstructions/instructions.go @@ -18,16 +18,14 @@ import ( const ( Filename = "AGENTS.md" MaxFileBytes = 64 << 10 - MaxTotalBytes = 256 << 10 + MaxTotalBytes = 128 << 10 MaxDirectories = 64 ) type Options struct { - Home string - DefaultDir string - Workdir string - GlobalFile string - DisableAutoLoad bool + Home string + DefaultDir string + Workdir string } type File struct { @@ -42,7 +40,6 @@ type File struct { } type Snapshot struct { - AutoLoad bool `json:"auto_load"` Workdir string `json:"workdir"` WorkspaceRoot string `json:"workspace_root"` Files []File `json:"files"` @@ -50,7 +47,6 @@ type Snapshot struct { type candidate struct { scope, path, root string - explicit bool } type loadedFile struct { @@ -61,7 +57,7 @@ type loadedFile struct { // 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{AutoLoad: !options.DisableAutoLoad, Workdir: options.Workdir, WorkspaceRoot: options.Workdir, Files: []File{}} + snapshot := Snapshot{Workdir: options.Workdir, WorkspaceRoot: options.Workdir, Files: []File{}} if err := ctx.Err(); err != nil { return snapshot, err } @@ -73,26 +69,19 @@ func Load(ctx context.Context, options Options) (Snapshot, error) { return snapshot, errors.New("instruction workdir must be an existing directory") } candidates := []candidate{} - if options.GlobalFile != "" { - if !filepath.IsAbs(options.GlobalFile) { - return snapshot, errors.New("global instruction file must be absolute") - } - candidates = append(candidates, candidate{scope: "global", path: options.GlobalFile, root: filepath.Dir(options.GlobalFile), explicit: true}) - } else if !options.DisableAutoLoad && options.Home != "" { + 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}) } - if !options.DisableAutoLoad { - 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]}) - } + 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) @@ -180,15 +169,7 @@ func within(root, path string) bool { func readCandidate(source candidate) (File, os.FileInfo) { file := File{Scope: source.scope, Path: filepath.Clean(source.path)} - // An explicitly configured file retains the existing symlink semantics. - // Automatic discovery never follows a leaf symlink into an unrelated file. - if source.explicit { - realPath, err := filepath.EvalSymlinks(source.path) - if err != nil { - return failedFile(file, err), nil - } - source.path, source.root = realPath, filepath.Dir(realPath) - } + // 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 @@ -255,22 +236,3 @@ func failedFile(file File, err error) File { } return file } - -// Text labels provenance and scope rather than promoting repository text into -// unqualified server/operator instructions. File errors remain visible to clients. -func (s Snapshot) Text() string { - var out strings.Builder - for _, file := range s.Files { - switch file.Status { - case "loaded": - fmt.Fprintf(&out, "\n\n### %s guidance\nSource: %q\n", file.Scope, file.Path) - if file.Scope == "workspace" { - fmt.Fprintf(&out, "Scope: %q and its descendants. Refines global guidance; does not override global safety requirements or the client's higher-priority instructions.\n", filepath.Dir(file.Path)) - } - fmt.Fprintf(&out, "SHA-256: %s\n\n%s", file.SHA256, file.Content) - case "error", "skipped": - fmt.Fprintf(&out, "\n\nInstruction file %q was not loaded (%s). Do not claim its rules were applied.", file.Path, file.Reason) - } - } - return strings.TrimSpace(out.String()) -} diff --git a/internal/agentinstructions/instructions_test.go b/internal/agentinstructions/instructions_test.go index bb847945..4658efb8 100644 --- a/internal/agentinstructions/instructions_test.go +++ b/internal/agentinstructions/instructions_test.go @@ -54,7 +54,7 @@ func TestLoadGlobalRootAndNestedInOrder(t *testing.T) { if got != want { t.Fatalf("ordered contents = %q, want %q", got, want) } - if snapshot.Workdir != child || snapshot.WorkspaceRoot != root || !snapshot.AutoLoad { + if snapshot.Workdir != child || snapshot.WorkspaceRoot != root { t.Fatalf("snapshot = %#v", snapshot) } for _, file := range snapshot.Files { @@ -62,9 +62,6 @@ func TestLoadGlobalRootAndNestedInOrder(t *testing.T) { t.Fatalf("missing provenance: %#v", file) } } - if !strings.Contains(snapshot.Text(), "does not override global safety") { - t.Fatal("workspace scope is not identified") - } } func TestLoadRefreshesEvenWhenSizeAndModificationTimeAreUnchanged(t *testing.T) { @@ -100,34 +97,6 @@ func TestLoadRefreshesEvenWhenSizeAndModificationTimeAreUnchanged(t *testing.T) } } -func TestExplicitGlobalOverrideAndDeduplication(t *testing.T) { - home, root := t.TempDir(), t.TempDir() - writeGuidance(t, home, "unused automatic global") - path := writeGuidance(t, root, "explicit rules") - snapshot := loadGuidance(t, Options{Home: home, DefaultDir: root, Workdir: root, GlobalFile: path}) - if len(snapshot.Files) != 2 || snapshot.Files[0].Scope != "global" || snapshot.Files[1].Status != "duplicate" || snapshot.Files[1].DuplicateOf != path { - t.Fatalf("dedup = %#v", snapshot) - } - if snapshot.Files[1].Content != "" || strings.Count(snapshot.Text(), "explicit rules") != 1 { - t.Fatal("same file injected twice") - } -} - -func TestDisableAutoLoadPreservesExplicitInstructions(t *testing.T) { - home, root := t.TempDir(), t.TempDir() - path := writeGuidance(t, home, "explicit global") - writeGuidance(t, root, "workspace rules") - options := Options{Home: home, DefaultDir: root, Workdir: root, DisableAutoLoad: true} - if snapshot := loadGuidance(t, options); snapshot.AutoLoad || len(snapshot.Files) != 0 { - t.Fatalf("autoload did not disable: %#v", snapshot) - } - options.GlobalFile = path - snapshot := loadGuidance(t, options) - if got := strings.Join(loadedContents(snapshot), "|"); got != "explicit global" { - t.Fatalf("explicit global not preserved: %s", got) - } -} - func TestRepositoryBoundaryDoesNotReadUnrelatedAncestorsOrSiblings(t *testing.T) { outer := t.TempDir() root, child := filepath.Join(outer, "repo"), filepath.Join(outer, "repo", "src") @@ -207,7 +176,7 @@ func TestNonRegularFileIsSkipped(t *testing.T) { } } -func TestAutomaticSymlinkIsNotFollowedButExplicitGlobalIsSupported(t *testing.T) { +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) @@ -218,10 +187,6 @@ func TestAutomaticSymlinkIsNotFollowedButExplicitGlobalIsSupported(t *testing.T) if len(loadedContents(snapshot)) != 0 || snapshot.Files[1].Reason != "not_regular_file" { t.Fatalf("followed automatic symlink: %#v", snapshot) } - snapshot = loadGuidance(t, Options{Home: home, DefaultDir: root, Workdir: root, GlobalFile: link}) - if snapshot.Files[0].Status != "loaded" || snapshot.Files[0].Path != link { - t.Fatalf("explicit file semantics changed: %#v", snapshot) - } } func TestHardLinkIsDeduplicated(t *testing.T) { @@ -264,9 +229,6 @@ func TestLoadRejectsInvalidSelectionAndHonorsCancellation(t *testing.T) { t.Fatalf("accepted workdir %q", workdir) } } - if _, err := Load(t.Context(), Options{Workdir: root, GlobalFile: "relative.md"}); err == nil { - t.Fatal("accepted relative explicit file") - } ctx, cancel := context.WithCancel(t.Context()) cancel() if _, err := Load(ctx, Options{DefaultDir: root, Workdir: root}); !errors.Is(err, context.Canceled) { diff --git a/internal/app/agentdock_context.go b/internal/app/agentdock_context.go index 69f1111b..6ae3d142 100644 --- a/internal/app/agentdock_context.go +++ b/internal/app/agentdock_context.go @@ -7,27 +7,22 @@ import ( "strings" "time" - "github.com/uvwt/agentdock/internal/agentinstructions" "github.com/uvwt/agentdock/internal/buildinfo" "github.com/uvwt/agentdock/internal/config" tooltask "github.com/uvwt/agentdock/internal/tool/task" ) func (r *Runtime) AgentDockContext(ctx context.Context) (Result, error) { - return r.agentDockContext(ctx, false, "") + return r.agentDockContext(ctx, false) } // AgentDockLocalContext 仅供 Nexus Bridge 使用。它不读取 Nexus 统一管理的 // Workflow/Recall,避免 fleet 聚合时按节点重复回灌共享上下文。 func (r *Runtime) AgentDockLocalContext(ctx context.Context) (Result, error) { - return r.agentDockContext(ctx, true, "") + return r.agentDockContext(ctx, true) } -func (r *Runtime) agentDockContext(ctx context.Context, nexusLocalOnly bool, workdir string) (Result, error) { - instructions, err := r.InstructionFiles(ctx, workdir) - if err != nil { - return nil, err - } +func (r *Runtime) agentDockContext(ctx context.Context, nexusLocalOnly bool) (Result, error) { skills, skillErr := r.skillCapabilityIndex() commonSkills, commonSkillErr := commonSkillCapabilityIndex() contextResult := capabilityContext{ @@ -38,23 +33,11 @@ func (r *Runtime) agentDockContext(ctx context.Context, nexusLocalOnly bool, wor 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 r.cfg.InstructionsFile == "" && strings.TrimSpace(r.cfg.Instructions) != "" { - contextResult.Rules = append(contextResult.Rules, "Additional operator instructions:\n"+r.cfg.Instructions) - } - if nexusLocalOnly { - // Keep the shared Bridge context contract unchanged. Device guidance travels - // through its existing rules field, not a node-specific schema extension. - if text := instructions.Text(); text != "" { - contextResult.Rules = append(contextResult.Rules, text) - } - } else { - contextResult.InstructionFiles = &instructions - contextResult.Rules = append(contextResult.Rules, "instruction_files.files 已自动载入规则正文;只应用 status=loaded 的条目,按全局、项目根目录、子目录顺序处理。项目规则不得削弱全局安全要求。操作其他工作区或规则文件已改变时,先调用 agentdock_context 并传入对应 workdir 刷新;该参数不会修改命令的默认工作目录。") - } if !nexusLocalOnly { // runtime 只保留模型操作主机所需的稳定环境事实;Nexus Bridge 已通过 Hello 持有这些节点事实, // 私有 context.local 不重复传输,避免两个来源长期漂移。 @@ -117,16 +100,17 @@ func (r *Runtime) agentDockContext(ctx context.Context, nexusLocalOnly bool, wor return result, nil } +type agentDockContextRequest struct{} + func (r *Runtime) agentDockContextTool(ctx context.Context, args map[string]any) (Result, error) { - var request contextRequest + var request agentDockContextRequest if err := decodeToolInput("agentdock_context", args, &request); err != nil { return nil, err } - return r.agentDockContext(ctx, false, request.Workdir) + return r.agentDockContext(ctx, false) } type capabilityContext struct { - InstructionFiles *agentinstructions.Snapshot `json:"instruction_files,omitempty"` Runtime *capabilityRuntimeContext `json:"runtime,omitempty"` Skills []capabilitySkillItem `json:"skills"` CommonSkills *capabilityCommonSkillIndex `json:"common_skills,omitempty"` 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 49bb427c..3e2c0fe8 100644 --- a/internal/app/contract_drift_test.go +++ b/internal/app/contract_drift_test.go @@ -69,7 +69,8 @@ func TestTypedToolRequestFieldsMatchPublishedSchemas(t *testing.T) { exact bool allowExtra []string }{ - {name: "agentdock_context", request: contextRequest{}, exact: true}, + {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/instruction_files.go b/internal/app/instruction_files.go deleted file mode 100644 index be2ea458..00000000 --- a/internal/app/instruction_files.go +++ /dev/null @@ -1,31 +0,0 @@ -package app - -import ( - "context" - "os" - - "github.com/uvwt/agentdock/internal/agentinstructions" -) - -// InstructionFiles selects guidance for this request only. It must not change -// Workspace.DefaultCWD: one Runtime can serve multiple independent clients. -func (r *Runtime) InstructionFiles(ctx context.Context, workdir string) (agentinstructions.Snapshot, error) { - if err := ctx.Err(); err != nil { - return agentinstructions.Snapshot{}, err - } - resolved, err := r.ws.ResolveExisting(workdir) - if err != nil { - return agentinstructions.Snapshot{}, toolErrorDetails("INVALID_ARGUMENT", "instruction 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 agentinstructions.Snapshot{}, toolErrorDetails("INVALID_ARGUMENT", "instruction workdir must be a directory", "validation", map[string]any{"workdir": workdir}) - } - return agentinstructions.Load(ctx, agentinstructions.Options{ - Home: r.cfg.AgentDockHome, - DefaultDir: r.ws.Root(), - Workdir: resolved.Abs, - GlobalFile: r.cfg.InstructionsFile, - DisableAutoLoad: r.cfg.AgentsAutoLoadDisabled, - }) -} diff --git a/internal/app/instruction_files_test.go b/internal/app/instruction_files_test.go deleted file mode 100644 index 10f3fad4..00000000 --- a/internal/app/instruction_files_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package app - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "sync" - "testing" - - "github.com/uvwt/agentdock/internal/agentinstructions" - "github.com/uvwt/agentdock/internal/config" -) - -func newInstructionRuntime(t *testing.T, configure func(*config.Config)) *Runtime { - t.Helper() - cfg := config.Config{AgentDockHome: t.TempDir(), AgentDockDefaultDir: t.TempDir()} - if configure != nil { - configure(&cfg) - } - 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 -} - -func writeInstructionFixture(t *testing.T, dir, content string) string { - t.Helper() - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, "AGENTS.md") - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - return path -} - -func instructionContext(t *testing.T, rt *Runtime, args map[string]any) capabilityContext { - t.Helper() - result, err := rt.Call(t.Context(), "agentdock_context", args) - if err != nil { - t.Fatal(err) - } - assertToolResultMatchestestOutputSchema(t, "agentdock_context", result) - var got capabilityContext - if err := remarshal(result, &got); err != nil { - t.Fatal(err) - } - if got.InstructionFiles == nil { - t.Fatal("instruction_files missing") - } - return got -} - -func instructionBodies(snapshot *agentinstructions.Snapshot) string { - var contents []string - for _, file := range snapshot.Files { - if file.Status == "loaded" { - contents = append(contents, file.Content) - } - } - return strings.Join(contents, "|") -} - -func TestInstructionContextLoadsGlobalAndWorkspaceWithoutACP(t *testing.T) { - rt := newInstructionRuntime(t, nil) - writeInstructionFixture(t, rt.cfg.AgentDockHome, "global rule") - writeInstructionFixture(t, rt.ws.Root(), "project rule") - got := instructionContext(t, rt, nil) - if body := instructionBodies(got.InstructionFiles); body != "global rule|project rule" { - t.Fatalf("guidance=%q", body) - } - if got.ACP != nil { - t.Fatal("autoload enabled ACP") - } - if !strings.Contains(strings.Join(got.Rules, "\n"), "status=loaded") { - t.Fatal("context does not explain which files to apply") - } -} - -func TestInstructionContextWorkspaceSelectionIsRequestLocal(t *testing.T) { - rt := newInstructionRuntime(t, nil) - root := rt.ws.Root() - writeInstructionFixture(t, rt.cfg.AgentDockHome, "global") - writeInstructionFixture(t, root, "default") - projectA, projectB := t.TempDir(), t.TempDir() - writeInstructionFixture(t, projectA, "project-a") - writeInstructionFixture(t, projectB, "project-b") - for _, test := range []struct{ workdir, want string }{ - {projectA, "global|project-a"}, {projectB, "global|project-b"}, {"", "global|default"}, - } { - got := instructionContext(t, rt, map[string]any{"workdir": test.workdir}) - if body := instructionBodies(got.InstructionFiles); body != test.want { - t.Fatalf("workdir=%q: %q", test.workdir, body) - } - if rt.ws.DefaultCWD() != root { - t.Fatal("request changed process-wide working directory") - } - } - writeInstructionFixture(t, filepath.Join(root, "subdir"), "child") - got := instructionContext(t, rt, map[string]any{"workdir": "subdir"}) - if body := instructionBodies(got.InstructionFiles); body != "global|default|child" { - t.Fatalf("relative selection=%q", body) - } -} - -func TestInstructionContextConcurrentWorkspacesRemainIsolated(t *testing.T) { - rt := newInstructionRuntime(t, nil) - root := rt.ws.DefaultCWD() - writeInstructionFixture(t, rt.cfg.AgentDockHome, "global") - projects := []string{t.TempDir(), t.TempDir()} - writeInstructionFixture(t, projects[0], "first") - writeInstructionFixture(t, projects[1], "second") - var wg sync.WaitGroup - for index := range 16 { - wg.Add(1) - go func(index int) { - defer wg.Done() - result, err := rt.Call(t.Context(), "agentdock_context", map[string]any{"workdir": projects[index%2]}) - if err != nil { - t.Error(err) - return - } - var got capabilityContext - if err := remarshal(result, &got); err != nil { - t.Error(err) - return - } - want := []string{"global|first", "global|second"}[index%2] - if got.InstructionFiles == nil || instructionBodies(got.InstructionFiles) != want { - t.Errorf("workspace context leaked across requests: %#v", got.InstructionFiles) - } - }(index) - } - wg.Wait() - if rt.ws.DefaultCWD() != root { - t.Fatal("concurrent selection changed default") - } -} - -func TestInstructionContextRefreshesExplicitFileInsteadOfStartupCopy(t *testing.T) { - explicitDir := t.TempDir() - path := writeInstructionFixture(t, explicitDir, "old-global-unique") - rt := newInstructionRuntime(t, func(cfg *config.Config) { cfg.InstructionsFile = path }) - writeInstructionFixture(t, rt.cfg.AgentDockHome, "unused automatic global") - writeInstructionFixture(t, rt.ws.Root(), "workspace") - before := instructionContext(t, rt, nil) - writeInstructionFixture(t, explicitDir, "new-global-unique") - after := instructionContext(t, rt, nil) - if body := instructionBodies(after.InstructionFiles); body != "new-global-unique|workspace" { - t.Fatalf("refresh=%q", body) - } - if before.InstructionFiles.Files[0].SHA256 == after.InstructionFiles.Files[0].SHA256 { - t.Fatal("explicit hash did not refresh") - } - if strings.Contains(strings.Join(after.Rules, "\n"), "old-global-unique") { - t.Fatal("startup instructions leaked after refresh") - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - removed := instructionContext(t, rt, nil) - if removed.InstructionFiles.Files[0].Status != "not_found" { - t.Fatal("removed explicit file remains loaded") - } -} - -func TestInstructionContextMissingSkippedAndDisabledStatesMatchSchema(t *testing.T) { - rt := newInstructionRuntime(t, nil) - missing := instructionContext(t, rt, nil) - if len(missing.InstructionFiles.Files) != 2 || instructionBodies(missing.InstructionFiles) != "" { - t.Fatalf("missing=%#v", missing.InstructionFiles) - } - writeInstructionFixture(t, rt.ws.Root(), strings.Repeat("x", agentinstructions.MaxFileBytes+1)) - skipped := instructionContext(t, rt, nil) - if skipped.InstructionFiles.Files[1].Reason != "file_size_limit" { - t.Fatal("oversized guidance not reported") - } - disabled := newInstructionRuntime(t, func(cfg *config.Config) { cfg.AgentsAutoLoadDisabled = true }) - writeInstructionFixture(t, disabled.cfg.AgentDockHome, "not loaded") - writeInstructionFixture(t, disabled.ws.Root(), "not loaded") - got := instructionContext(t, disabled, nil) - if got.InstructionFiles.AutoLoad || len(got.InstructionFiles.Files) != 0 { - t.Fatal("disabled autoload read files") - } -} - -func TestInstructionContextBridgePreservesSharedShape(t *testing.T) { - rt := newInstructionRuntime(t, nil) - writeInstructionFixture(t, rt.cfg.AgentDockHome, "bridge-global-marker") - writeInstructionFixture(t, rt.ws.Root(), "bridge-project-marker") - result, err := rt.AgentDockLocalContext(t.Context()) - if err != nil { - t.Fatal(err) - } - if _, exists := result["instruction_files"]; exists { - t.Fatal("local-only extension leaked into shared Bridge context") - } - var got capabilityContext - if err := remarshal(result, &got); err != nil { - t.Fatal(err) - } - rules := strings.Join(got.Rules, "\n") - for _, marker := range []string{"bridge-global-marker", "bridge-project-marker"} { - if strings.Count(rules, marker) != 1 { - t.Fatalf("Bridge missing or repeated %s", marker) - } - } -} - -func TestInstructionContextRejectsInvalidWorkdirAndUnknownFields(t *testing.T) { - rt := newInstructionRuntime(t, nil) - file := writeInstructionFixture(t, rt.ws.Root(), "rules") - for _, args := range []map[string]any{ - {"workdir": 42}, {"workdir": nil}, {"workdir": file}, - {"workdir": filepath.Join(rt.ws.Root(), "missing")}, - {"workdir": "bad\x00path"}, {"unknown": true}, {"workdir": strings.Repeat("x", 4097)}, - } { - _, err := rt.Call(t.Context(), "agentdock_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, "agentdock_context", nil); !errors.Is(err, context.Canceled) { - t.Fatalf("cancellation=%v", err) - } -} diff --git a/internal/app/mcp_contract_test.go b/internal/app/mcp_contract_test.go index dee156e6..9796cd8f 100644 --- a/internal/app/mcp_contract_test.go +++ b/internal/app/mcp_contract_test.go @@ -1,7 +1,6 @@ package app import ( - "maps" "reflect" "testing" @@ -26,12 +25,6 @@ func TestCanonicalToolDefinitionsMatchSharedContract(t *testing.T) { } wantInput, _ := mcpcontract.InputSchema(name) actualInput, actualOutput := definition.InputSchema, definition.OutputSchema - if name == mcpcontract.ToolAgentDockContext { - // Standalone AgentDock adds only optional local context fields. Compare - // every remaining field against the unchanged shared protocol contract. - actualInput = withoutLocalContextProperty(t, actualInput, "workdir") - actualOutput = withoutLocalContextProperty(t, actualOutput, "instruction_files") - } if !reflect.DeepEqual(actualInput, wantInput) { t.Fatalf("%s input schema drifted from shared contract", name) } @@ -59,21 +52,3 @@ func TestCanonicalToolDefinitionsMatchSharedContract(t *testing.T) { } } } - -func withoutLocalContextProperty(t *testing.T, schema map[string]any, name string) map[string]any { - t.Helper() - copy := maps.Clone(schema) - properties := maps.Clone(schema["properties"].(map[string]any)) - if properties[name] == nil { - t.Fatalf("local context extension %q missing", name) - } - requiredFields, _ := schema["required"].([]string) - for _, required := range requiredFields { - if required == name { - t.Fatalf("local extension %q must remain optional", name) - } - } - delete(properties, name) - copy["properties"] = properties - return copy -} 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 01d1ebb5..49b750dd 100644 --- a/internal/app/specs_context.go +++ b/internal/app/specs_context.go @@ -1,70 +1,20 @@ package app -import ( - "maps" - - "github.com/uvwt/agentdock/internal/agentinstructions" - "github.com/uvwt/agentdock/internal/config" -) - -type contextRequest struct { - Workdir string `json:"workdir,omitempty"` -} - func contextToolSpecs() []ToolSpec { - return []ToolSpec{{ - Name: "agentdock_context", Contract: contextToolContract, Title: "AgentDock context", - Description: "Return structured AgentDock bootstrap context including capabilities, integrations, rules, and automatically loaded global/workspace AGENTS.md content. Call before project operations; pass workdir when selecting another workspace or refreshing changed rules. Selection is request-local and never changes command defaults.", - Handler: ctxToolHandler((*Runtime).agentDockContextTool), - }} -} - -// The standalone entrypoint adds optional local fields without changing the -// shared Nexus Bridge contract. All existing canonical fields remain identical. -func contextToolContract(name string, cfg config.Config) (ToolContract, bool) { - contract, ok := canonicalToolContract(name, cfg) - if !ok { - return ToolContract{}, false - } - contract.InputSchema = maps.Clone(contract.InputSchema) - input := maps.Clone(contract.InputSchema["properties"].(map[string]any)) - input["workdir"] = map[string]any{ - "type": "string", "maxLength": 4096, - "description": "Existing host workspace directory. Omit or use an empty string for the current default; relative and ~/ paths use Host resolution. Does not change any session or command working directory.", - } - contract.InputSchema["properties"] = input - contract.OutputSchema = maps.Clone(contract.OutputSchema) - output := maps.Clone(contract.OutputSchema["properties"].(map[string]any)) - output["instruction_files"] = instructionFilesSchema() - contract.OutputSchema["properties"] = output - return contract, true -} - -func instructionFilesSchema() map[string]any { - return map[string]any{ - "type": "object", "additionalProperties": false, - "required": []string{"auto_load", "workdir", "workspace_root", "files"}, - "properties": map[string]any{ - "auto_load": map[string]any{"type": "boolean"}, - "workdir": map[string]any{"type": "string"}, - "workspace_root": map[string]any{"type": "string"}, - "files": map[string]any{ - "type": "array", "maxItems": agentinstructions.MaxDirectories + 1, - "items": map[string]any{ - "type": "object", "additionalProperties": false, - "required": []string{"scope", "path", "status"}, - "properties": map[string]any{ - "scope": map[string]any{"type": "string", "enum": []string{"global", "workspace"}}, - "path": map[string]any{"type": "string"}, - "status": map[string]any{"type": "string", "enum": []string{"loaded", "not_found", "empty", "duplicate", "skipped", "error"}}, - "content": map[string]any{"type": "string", "maxLength": agentinstructions.MaxFileBytes}, - "sha256": map[string]any{"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "size_bytes": map[string]any{"type": "integer", "minimum": 0}, - "reason": map[string]any{"type": "string"}, - "duplicate_of": map[string]any{"type": "string"}, - }, - }, - }, + 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/agents_autoload_test.go b/internal/config/agents_autoload_test.go deleted file mode 100644 index 4d5f4658..00000000 --- a/internal/config/agents_autoload_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package config - -import "testing" - -func TestAgentsAutoLoadEnvironment(t *testing.T) { - for _, test := range []struct { - value string - disabled bool - invalid bool - }{ - {value: ""}, - {value: "true"}, - {value: "1"}, - {value: "false", disabled: true}, - {value: "0", disabled: true}, - {value: "unexpected", invalid: true}, - } { - t.Run("value="+test.value, func(t *testing.T) { - t.Setenv("AGENTDOCK_AGENTS_AUTOLOAD", test.value) - t.Setenv("AGENTDOCK_ACP_ENABLED", "false") - cfg, err := FromEnv() - if test.invalid { - if err == nil { - t.Fatal("accepted invalid autoload boolean") - } - return - } - if err != nil { - t.Fatal(err) - } - if cfg.AgentsAutoLoadDisabled != test.disabled { - t.Fatalf("disabled=%v, want %v", cfg.AgentsAutoLoadDisabled, test.disabled) - } - }) - } -} diff --git a/internal/config/config.go b/internal/config/config.go index 82f4fcc0..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,9 +51,6 @@ type Config struct { ACPInteractionMS int Stdio bool TrustedProxyCIDRs []string - InstructionsFile string - Instructions string - AgentsAutoLoadDisabled bool } // ACPProfile 表示一个可独立运行、独立持久化会话的 ACP 实例。 @@ -73,10 +66,6 @@ type ACPProfile struct { } func FromEnv() (Config, error) { - agentsAutoLoad, err := getenvBool("AGENTDOCK_AGENTS_AUTOLOAD", true) - if err != nil { - return Config{}, err - } port, err := getenvInt("AGENTDOCK_PORT", 8765) if err != nil { return Config{}, err @@ -167,8 +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")), - AgentsAutoLoadDisabled: !agentsAutoLoad, }, nil } @@ -232,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/bootstrap_instructions.go b/internal/mcp/bootstrap_instructions.go deleted file mode 100644 index 0d3ccc48..00000000 --- a/internal/mcp/bootstrap_instructions.go +++ /dev/null @@ -1,30 +0,0 @@ -package mcp - -import ( - "context" - - "github.com/uvwt/agentdock/internal/app" - "github.com/uvwt/agentdock/internal/config" -) - -func initialServerInstructions(runtime *app.Runtime, cfg config.Config) string { - custom := cfg.Instructions - if runtime != nil && cfg.InstructionsFile != "" { - // The same explicitly configured file is loaded below with provenance. - // Do not duplicate the startup copy or later re-expose stale file content. - custom = "" - } - instructions := serverInstructions(cfg.NexusEndpoint != "", custom) - instructions += "\n\nBefore operating on a project, call agentdock_context with its workdir to receive current global and workspace AGENTS.md guidance. Apply only loaded files in their reported order. Workspace guidance must not weaken global safety requirements or the client's higher-priority instructions. Refresh after workspace/rule changes. workdir selection does not change command defaults." - if runtime == nil { - return instructions - } - files, err := runtime.InstructionFiles(context.Background(), "") - if err != nil { - return instructions + "\n\nAutomatic AGENTS.md startup loading failed. Call agentdock_context to diagnose before project operations; do not assume rules were loaded." - } - if text := files.Text(); text != "" { - instructions += "\n\nAutomatically loaded instruction files (startup snapshot, scoped to the reported directories; refresh with agentdock_context):\n" + text - } - return instructions -} diff --git a/internal/mcp/bootstrap_instructions_test.go b/internal/mcp/bootstrap_instructions_test.go deleted file mode 100644 index a62d4a71..00000000 --- a/internal/mcp/bootstrap_instructions_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package mcp - -import ( - "context" - "encoding/json" - "io" - "os" - "path/filepath" - "strings" - "testing" - "time" - - mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/uvwt/agentdock/internal/agentinstructions" - "github.com/uvwt/agentdock/internal/app" - "github.com/uvwt/agentdock/internal/config" -) - -func writeBootstrapInstructions(t *testing.T, dir, text string) string { - t.Helper() - path := filepath.Join(dir, "AGENTS.md") - if err := os.WriteFile(path, []byte(text), 0o600); err != nil { - t.Fatal(err) - } - return path -} - -func connectInstructionClient(t *testing.T, server *Server) *mcpsdk.ClientSession { - t.Helper() - 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: "instruction-test", Version: "1.0.0"}, nil) - // T.Context is canceled before Cleanup; this session must remain alive - // until Cleanup closes it and observes the server's orderly shutdown. - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - session, err := client.Connect(ctx, &mcpsdk.IOTransport{Reader: clientInput, Writer: clientOutput}, nil) - if err != nil { - cancel() - _ = clientInput.Close() - _ = clientOutput.Close() - _ = serverInput.Close() - _ = serverOutput.Close() - t.Fatal(err) - } - t.Cleanup(func() { - defer cancel() - defer clientInput.Close() - defer clientOutput.Close() - defer serverInput.Close() - defer serverOutput.Close() - if err := session.Close(); err != nil { - t.Error(err) - } - select { - case err := <-serverDone: - if err != nil { - t.Error(err) - } - case <-time.After(5 * time.Second): - t.Error("instruction test server did not stop") - } - }) - return session -} - -func TestInstructionBootstrapAndLiveContextThroughMCP(t *testing.T) { - for _, explicit := range []bool{false, true} { - name := "automatic" - if explicit { - name = "explicit global override" - } - t.Run(name, func(t *testing.T) { - cfg := config.Config{AgentDockHome: t.TempDir(), AgentDockDefaultDir: t.TempDir()} - global := writeBootstrapInstructions(t, cfg.AgentDockHome, "global-before-marker") - writeBootstrapInstructions(t, cfg.AgentDockDefaultDir, "workspace-before-marker") - if explicit { - cfg.InstructionsFile = global - } - if err := cfg.Normalize(); err != nil { - t.Fatal(err) - } - rt, err := app.NewRuntime(cfg) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = rt.Close() }) - session := connectInstructionClient(t, NewServer(rt, cfg)) - initial := session.InitializeResult().Instructions - for _, marker := range []string{"global-before-marker", "workspace-before-marker"} { - if strings.Count(initial, marker) != 1 { - t.Fatalf("initial instructions missing/repeated %q: %s", marker, initial) - } - } - if strings.Index(initial, "global-before-marker") > strings.Index(initial, "workspace-before-marker") { - t.Fatal("global rules must precede workspace rules") - } - writeBootstrapInstructions(t, cfg.AgentDockHome, "global-after-marker") - selected := t.TempDir() - writeBootstrapInstructions(t, selected, "selected-workspace-marker") - result, err := session.CallTool(t.Context(), &mcpsdk.CallToolParams{Name: "agentdock_context", Arguments: map[string]any{"workdir": selected}}) - if err != nil { - t.Fatal(err) - } - if result.IsError { - t.Fatalf("MCP context failed: %#v", result) - } - encoded, err := json.Marshal(result.StructuredContent) - if err != nil { - t.Fatal(err) - } - var got struct { - Instructions agentinstructions.Snapshot `json:"instruction_files"` - } - if err := json.Unmarshal(encoded, &got); err != nil { - t.Fatal(err) - } - if len(got.Instructions.Files) != 2 || got.Instructions.Files[0].Content != "global-after-marker" || got.Instructions.Files[1].Content != "selected-workspace-marker" { - t.Fatalf("live MCP instructions=%s", encoded) - } - for _, old := range []string{"global-before-marker", "workspace-before-marker"} { - if strings.Contains(string(encoded), old) { - t.Fatalf("live response leaked stale text: %s", old) - } - } - if rt.Workspace().DefaultCWD() == selected { - t.Fatal("MCP request changed workspace default") - } - }) - } -} - -func TestInstructionBootstrapOptOutAndFileErrors(t *testing.T) { - cfg := config.Config{AgentDockHome: t.TempDir(), AgentDockDefaultDir: t.TempDir(), AgentsAutoLoadDisabled: true} - writeBootstrapInstructions(t, cfg.AgentDockHome, "disabled-global-marker") - writeBootstrapInstructions(t, cfg.AgentDockDefaultDir, "disabled-workspace-marker") - if err := cfg.Normalize(); err != nil { - t.Fatal(err) - } - rt, err := app.NewRuntime(cfg) - if err != nil { - t.Fatal(err) - } - defer rt.Close() - text := initialServerInstructions(rt, cfg) - if strings.Contains(text, "disabled-global-marker") || strings.Contains(text, "disabled-workspace-marker") { - t.Fatal("opt-out still injected automatic files") - } - cfg.AgentsAutoLoadDisabled = false - writeBootstrapInstructions(t, cfg.AgentDockHome, strings.Repeat("x", agentinstructions.MaxFileBytes+1)) - enabled, err := app.NewRuntime(cfg) - if err != nil { - t.Fatal(err) - } - defer enabled.Close() - text = initialServerInstructions(enabled, cfg) - if !strings.Contains(text, "file_size_limit") || !strings.Contains(text, "Do not claim its rules were applied") { - t.Fatal("startup file failure is not disclosed") - } - if strings.Contains(text, strings.Repeat("x", 100)) { - t.Fatal("oversized startup body leaked") - } -} 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 ae8e2b0e..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) } @@ -104,8 +104,8 @@ func TestAgentDockContextSchemaIsStructuredEntrypoint(t *testing.T) { } inputProps := schemaProperties(t, "agentdock_context") - if len(inputProps) != 1 || inputProps["workdir"] == nil { - t.Fatalf("agentdock_context should expose only the optional request-local workdir selector: %#v", inputProps) + if len(inputProps) != 0 { + 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) @@ -115,11 +115,14 @@ func TestAgentDockContextSchemaIsStructuredEntrypoint(t *testing.T) { if !ok { t.Fatal("agentdock_context output schema properties missing") } - for _, name := range []string{"runtime", "skills", "dynamic_mcp", "acp", "workflow_templates", "recall", "rules", "warnings", "instruction_files"} { + for _, name := range []string{"runtime", "skills", "dynamic_mcp", "acp", "workflow_templates", "recall", "rules", "warnings"} { if _, ok := outputProps[name]; !ok { 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) } @@ -129,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 570ec2da..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: initialServerInstructions(runtime, cfg), + 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(),