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] 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},