diff --git a/.repository-projection.json b/.repository-projection.json index 33dfa60..dd5182e 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "15bae766017b01af460b1ae3a2fa632142b8aad1", + "sourceSha": "7f22f2c74341d38f7af4363fa622e7452fa49c51", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "f7a844a1e78dd168999bd9b5b99417b6af9e5a08", + "priorProjectedBase": "972e31a9b50f07646fa5eba754a24ebe72d41fd0", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6", - "contentDigest": "95283b9922d0c07119b0765424cbda9d93d3f7276b65e0b671f7f855b47f1e1f", + "contentDigest": "d6d587b012b854ec14ccb147408e99003b7c232ce6003d88341ed5ddf8b6d1be", "publicationEligible": true } diff --git a/Cargo.lock b/Cargo.lock index 206eda6..c70ba41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,6 +601,7 @@ dependencies = [ "serde_yaml", "sha2", "tokio", + "toml", "ureq", ] @@ -909,6 +910,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -1092,6 +1102,47 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" version = "1.20.1" @@ -1265,6 +1316,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "zeroize" version = "1.9.0" diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index 68ca74a..a989f57 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -203,24 +203,111 @@ private func configuredMacAgentWorkspaceRoots() -> [String] { }.prefix(8)) } -private func macProjectDirectory(_ path: String) -> Bool { +private let macProjectDirectoryFlags = O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK + +// /var, /tmp, and /etc are system-owned aliases to /private on macOS. Preserve +// those configured roots while refusing every other symlink in the path walk. +private func canonicalMacProjectRoot(_ path: String) -> String { + for alias in ["var", "tmp", "etc"] where path == "/\(alias)" || path.hasPrefix("/\(alias)/") { + return "/private" + path + } + return path +} + +func openMacProjectRoot(_ path: String) -> Int32? { + guard path.hasPrefix("/"), path.utf8.count <= 512, !path.utf8.contains(0), + !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) else { return nil } + let resolved = canonicalMacProjectRoot(path) + var fd = open("/", macProjectDirectoryFlags) + guard fd >= 0 else { return nil } + for component in resolved.split(separator: "/") { + let next = openat(fd, String(component), macProjectDirectoryFlags) + close(fd) + guard next >= 0 else { return nil } + fd = next + } + return fd +} + +private func openMacProjectSubdirectory(_ parentFD: Int32, _ path: String) -> Int32? { + var fd = dup(parentFD) + guard fd >= 0 else { return nil } + for component in path.split(separator: "/") { + guard component != "." && component != ".." else { close(fd); return nil } + let next = openat(fd, String(component), macProjectDirectoryFlags) + close(fd) + guard next >= 0 else { return nil } + fd = next + } + return fd +} + +private func boundedMacProjectEntries(_ fd: Int32) -> [String] { + let copy = dup(fd) + guard copy >= 0 else { return [] } + guard let directory = fdopendir(copy) else { close(copy); return [] } + defer { closedir(directory) } + var names = [String]() + while names.count < 256, let entry = readdir(directory) { + let name = withUnsafePointer(to: &entry.pointee.d_name) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: 1) { String(cString: $0) } + } + if name != "." && name != ".." { names.append(name) } + } + return names.sorted() +} + +private func macProjectEntryType(_ fd: Int32, _ name: String) -> mode_t? { var info = stat() - return lstat(path, &info) == 0 && (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFDIR) + guard fstatat(fd, name, &info, AT_SYMLINK_NOFOLLOW) == 0 else { return nil } + return info.st_mode & mode_t(S_IFMT) +} + +private func readMacProjectFile(_ fd: Int32, _ name: String) -> Data? { + let fileFD = openat(fd, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard fileFD >= 0 else { return nil } + defer { close(fileFD) } + var info = stat() + guard fstat(fileFD, &info) == 0, (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG), + info.st_size >= 0, info.st_size <= 64 << 10 else { return nil } + var bytes = [UInt8](repeating: 0, count: Int(info.st_size) + 1) + let capacity = bytes.count + let count = read(fileFD, &bytes, capacity) + guard count >= 0, count <= 64 << 10 else { return nil } + return Data(bytes.prefix(count)) } func collectMacProjectAgentDiscovery(roots: [String]) -> (servers: [DeviceMCPServer], assets: [DeviceAgentAsset]) { + let rootFDs = roots.prefix(8).compactMap(openMacProjectRoot) + return collectMacProjectAgentDiscovery(rootFDs: rootFDs) +} + +// Takes ownership of rootFDs. Kept internal so tests can replace a root path +// after opening it and prove subsequent probes stay bound to that directory. +func collectMacProjectAgentDiscovery(rootFDs: [Int32]) -> (servers: [DeviceMCPServer], assets: [DeviceAgentAsset]) { + defer { rootFDs.forEach { close($0) } } let configs: [(String, String, Bool)] = [("claude", ".mcp.json", false), ("claude", ".claude/settings.json", false), ("cursor", ".cursor/mcp.json", false), ("codex", ".codex/config.toml", true), ("opencode", ".opencode/opencode.json", false), ("agents", ".agents/mcp.json", false)] let assetDirs: [(String, String, String, String)] = [("agents", "skill", ".agents/skills", "skill"), ("claude", "skill", ".claude/skills", "skill"), ("claude", "agent", ".claude/agents", "md"), ("claude", "plugin", ".claude/plugins", "plugin"), ("codex", "skill", ".codex/skills", "skill"), ("cursor", "skill", ".cursor/skills", "skill"), ("maestro", "plugin", ".maestro/plugins", "plugin"), ("maestro", "plugin", ".composer/plugins", "plugin")] var found = Set() var assets = Set() var pluginConfigReads = 0 - for root in roots.prefix(8) where macProjectDirectory(root) { - let children = boundedAgentDirectoryEntries(root).filter { macProjectDirectory("\(root)/\($0)") }.prefix(32).map { "\(root)/\($0)" } - for project in [root] + children { + let deadline = DispatchTime.now().uptimeNanoseconds + 3_000_000_000 + for rootFD in rootFDs.prefix(8) where DispatchTime.now().uptimeNanoseconds < deadline { + var projects: [(fd: Int32, depth: Int)] = [(dup(rootFD), 0)] + defer { projects.forEach { if $0.fd >= 0 { close($0.fd) } } } + var next = 0 + // Breadth first: direct children retain priority within 32 descendants. + while next < projects.count && DispatchTime.now().uptimeNanoseconds < deadline { + let project = projects[next] + next += 1 + guard project.fd >= 0 else { continue } for (client, relative, isTOML) in configs { - let path = "\(project)/\(relative)" - if relative.contains("/"), !macProjectDirectory("\(project)/\(relative.split(separator: "/")[0])") { continue } - guard let data = readAgentConfigNoFollow(path) else { continue } + let parts = relative.split(separator: "/").map(String.init) + let parentFD = parts.count == 1 ? dup(project.fd) : openMacProjectSubdirectory(project.fd, parts.dropLast().joined(separator: "/")) + guard let parentFD else { continue } + let data = readMacProjectFile(parentFD, parts.last!) + close(parentFD) + guard let data else { continue } assets.insert("\(client)\u{0}config\u{0}project\u{0}project/\(relative)") if relative == ".claude/settings.json", let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], @@ -242,18 +329,16 @@ func collectMacProjectAgentDiscovery(roots: [String]) -> (servers: [DeviceMCPSer } } for (client, kind, relative, format) in assetDirs { - let parts = relative.split(separator: "/") - guard parts.count == 2, macProjectDirectory("\(project)/\(parts[0])") else { continue } - let directory = "\(project)/\(relative)" - for entry in boundedAgentDirectoryEntries(directory) { - let path = "\(directory)/\(entry)" - var info = stat() - guard lstat(path, &info) == 0 else { continue } - let type = info.st_mode & mode_t(S_IFMT) + guard let directoryFD = openMacProjectSubdirectory(project.fd, relative) else { continue } + defer { close(directoryFD) } + for entry in boundedMacProjectEntries(directoryFD) { + guard let type = macProjectEntryType(directoryFD, entry) else { continue } let name: String? if format == "skill" && type == mode_t(S_IFDIR) { - var manifest = stat() - name = lstat("\(path)/SKILL.md", &manifest) == 0 && (manifest.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) ? entry : nil + if let skillFD = openMacProjectSubdirectory(directoryFD, entry) { + name = macProjectEntryType(skillFD, "SKILL.md") == mode_t(S_IFREG) ? entry : nil + close(skillFD) + } else { name = nil } } else if format == "plugin" && type == mode_t(S_IFDIR) { name = entry } else if format == "md" && type == mode_t(S_IFREG) && entry.hasSuffix(".md") { @@ -262,9 +347,11 @@ func collectMacProjectAgentDiscovery(roots: [String]) -> (servers: [DeviceMCPSer if let name, safeAgentAssetName(name) { assets.insert("\(client)\u{0}\(kind)\u{0}\(name)\u{0}project/\(relative)") if client == "maestro" && kind == "plugin" { + guard let pluginFD = openMacProjectSubdirectory(directoryFD, entry) else { continue } + defer { close(pluginFD) } for config in ["mcp.json", ".mcp.json"] where pluginConfigReads < 32 { pluginConfigReads += 1 - guard let data = readAgentConfigNoFollow("\(path)/\(config)") else { continue } + guard let data = readMacProjectFile(pluginFD, config) else { continue } let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] for (server, transport) in mcpEntries(object?["mcpServers"] ?? object?["servers"]) where safeAgentAssetName(server) { found.insert("maestro\u{0}\(server)\u{0}project/\(relative)/*/\(config)\u{0}\(transport)") @@ -274,6 +361,14 @@ func collectMacProjectAgentDiscovery(roots: [String]) -> (servers: [DeviceMCPSer } } } + if project.depth < 2 { + for entry in boundedMacProjectEntries(project.fd) { + guard projects.count < 33, DispatchTime.now().uptimeNanoseconds < deadline else { break } + if let childFD = openMacProjectSubdirectory(project.fd, entry) { + projects.append((childFD, project.depth + 1)) + } + } + } } } return (found.sorted().prefix(128).compactMap { entry in @@ -325,6 +420,7 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ var found = Set() var assetNames = Set() var pluginConfigReads = 0 + var cursorPluginDirectoriesSeen = 0 for home in homes.prefix(65) { let assetDirs: [(String, String, String, String)] = [ ("agents", "skill", ".agents/skills", "skill"), @@ -389,6 +485,36 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ } if assetNames.count >= 128 { break } } + if let agents = openAgentDirectoryNoFollow(home: home, relative: ".cursor/agents") { + defer { close(agents) } + for entry in boundedAgentDirectoryEntries(descriptor: agents) where entry.hasSuffix(".md") && assetNames.count < 128 { + let name = String(entry.dropLast(3)) + var info = stat() + guard safeAgentAssetName(name), fstatat(agents, entry, &info, AT_SYMLINK_NOFOLLOW) == 0, + info.st_mode & S_IFMT == S_IFREG, info.st_size >= 0, info.st_size <= 64 << 10 else { continue } + assetNames.insert("cursor\u{0}agent\u{0}\(name)\u{0}.cursor/agents") + } + } + if let localPlugins = openAgentDirectoryNoFollow(home: home, relative: ".cursor/plugins/local") { + defer { close(localPlugins) } + for entry in boundedAgentDirectoryEntries(descriptor: localPlugins) where cursorPluginDirectoriesSeen < 32 && assetNames.count < 128 { + guard safeAgentAssetName(entry) else { continue } + let plugin = openat(localPlugins, entry, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard plugin >= 0 else { continue } + defer { close(plugin) } + cursorPluginDirectoriesSeen += 1 + let direct = readAgentConfigNoFollow(descriptor: plugin, name: "plugin.json") + let cursor = openat(plugin, ".cursor-plugin", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + let nested = cursor >= 0 ? readAgentConfigNoFollow(descriptor: cursor, name: "plugin.json") : nil + if cursor >= 0 { close(cursor) } + if [direct, nested].compactMap({ $0 }).contains(where: { data in + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + return object?["name"] as? String == entry + }) { + assetNames.insert("cursor\u{0}plugin\u{0}\(entry)\u{0}.cursor/plugins/local") + } + } + } for (client, relative, isTOML) in configs { guard let data = readAgentConfigNoFollow("\(home)/\(relative)") else { continue } assetNames.insert("\(client)\u{0}config\u{0}user\u{0}\(relative)") @@ -435,6 +561,44 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ return (clis, apps, servers, assets) } +private func openAgentDirectoryNoFollow(home: String, relative: String) -> Int32? { + var directory = open(home, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard directory >= 0 else { return nil } + for segment in relative.split(separator: "/") { + let next = openat(directory, String(segment), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + close(directory) + guard next >= 0 else { return nil } + directory = next + } + return directory +} + +private func boundedAgentDirectoryEntries(descriptor: Int32) -> [String] { + let copy = dup(descriptor) + guard copy >= 0 else { return [] } + guard let directory = fdopendir(copy) else { close(copy); return [] } + defer { closedir(directory) } + var names = [String]() + while names.count < 256, let entry = readdir(directory) { + let name = withUnsafePointer(to: &entry.pointee.d_name) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: 1) { String(cString: $0) } + } + if name != "." && name != ".." { names.append(name) } + } + return names.sorted() +} + +private func readAgentConfigNoFollow(descriptor: Int32, name: String) -> Data? { + let file = openat(descriptor, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard file >= 0 else { return nil } + defer { close(file) } + var info = stat() + guard fstat(file, &info) == 0, info.st_mode & S_IFMT == S_IFREG, + info.st_size >= 0, info.st_size <= 64 << 10 else { return nil } + let data = FileHandle(fileDescriptor: file, closeOnDealloc: false).readData(ofLength: (64 << 10) + 1) + return data.count <= 64 << 10 ? data : nil +} + private func mcpEntries(_ raw: Any?) -> [(String, String)] { guard let definitions = raw as? [String: Any] else { return [] } return definitions.map { name, rawDefinition in diff --git a/macos/Tests/MerlinMacOSTests/SyncTests.swift b/macos/Tests/MerlinMacOSTests/SyncTests.swift index e2c7917..ba60cc2 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -206,6 +206,57 @@ struct SyncTests { #expect(collectMacAgentDiscovery(homes: [home], systemBins: []).servers.count == 5) } + @Test("Cursor user agents and local plugins require bounded regular files and real manifests") + func cursorUserAssets() throws { + let home = NSTemporaryDirectory() + "merlin-cursor-assets-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: home) } + let cursor = home + "/.cursor" + try FileManager.default.createDirectory(atPath: cursor + "/agents", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: cursor + "/plugins/local/portable", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: cursor + "/plugins/local/native/.cursor-plugin", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: cursor + "/plugins/local/invalid", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: cursor + "/plugins/local/mismatch", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: cursor + "/plugins/local/linked", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: cursor + "/plugins/local/oversized", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: cursor + "/plugins/local/nested-link", withIntermediateDirectories: true) + try "secret prompt".write(toFile: cursor + "/agents/reviewer.md", atomically: true, encoding: .utf8) + try "oversized".padding(toLength: (64 << 10) + 1, withPad: "x", startingAt: 0).write(toFile: cursor + "/agents/oversized.md", atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(atPath: cursor + "/agents/linked.md", withDestinationPath: cursor + "/agents/reviewer.md") + try #"{"name":"portable","description":"secret"}"#.write(toFile: cursor + "/plugins/local/portable/plugin.json", atomically: true, encoding: .utf8) + try #"{"name":"native","description":"secret"}"#.write(toFile: cursor + "/plugins/local/native/.cursor-plugin/plugin.json", atomically: true, encoding: .utf8) + try "not JSON".write(toFile: cursor + "/plugins/local/invalid/plugin.json", atomically: true, encoding: .utf8) + try #"{"name":"another"}"#.write(toFile: cursor + "/plugins/local/mismatch/plugin.json", atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(atPath: cursor + "/plugins/local/linked/plugin.json", withDestinationPath: cursor + "/plugins/local/portable/plugin.json") + try (#"{"name":"oversized","padding":""# + String(repeating: "x", count: 64 << 10) + #""}"#).write(toFile: cursor + "/plugins/local/oversized/plugin.json", atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(atPath: cursor + "/plugins/local/nested-link/.cursor-plugin", withDestinationPath: cursor + "/plugins/local/native/.cursor-plugin") + let assets = collectMacAgentDiscovery(homes: [home], systemBins: []).assets + #expect(assets.contains { $0.client == "cursor" && $0.kind == "agent" && $0.name == "reviewer" && $0.source == ".cursor/agents" }) + #expect(assets.contains { $0.client == "cursor" && $0.kind == "plugin" && $0.name == "portable" && $0.source == ".cursor/plugins/local" }) + #expect(assets.contains { $0.client == "cursor" && $0.kind == "plugin" && $0.name == "native" }) + #expect(!assets.contains { ["linked", "oversized", "invalid", "mismatch", "nested-link"].contains($0.name) }) + #expect(!String(decoding: try JSONEncoder().encode(assets), as: UTF8.self).contains("secret")) + + try FileManager.default.removeItem(atPath: cursor + "/agents") + try FileManager.default.createSymbolicLink(atPath: cursor + "/agents", withDestinationPath: cursor + "/plugins/local") + #expect(!collectMacAgentDiscovery(homes: [home], systemBins: []).assets.contains { $0.client == "cursor" && $0.kind == "agent" }) + } + + @Test("Cursor local plugin manifest probes stop after 32 directories") + func cursorPluginProbeCap() throws { + let home = NSTemporaryDirectory() + "merlin-cursor-cap-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: home) } + let local = home + "/.cursor/plugins/local" + for index in 0...32 { + let name = String(format: "plugin-%02d", index) + try FileManager.default.createDirectory(atPath: local + "/" + name, withIntermediateDirectories: true) + try "{\"name\":\"\(name)\"}".write(toFile: local + "/" + name + "/plugin.json", atomically: true, encoding: .utf8) + } + let plugins = collectMacAgentDiscovery(homes: [home], systemBins: []).assets.filter { $0.client == "cursor" && $0.kind == "plugin" } + #expect(plugins.count == 32) + #expect(plugins.contains { $0.name == "plugin-31" }) + #expect(!plugins.contains { $0.name == "plugin-32" }) + } + @Test("project discovery reports fixed labels and ignores linked configs") func projectAgentDiscovery() throws { let root = NSTemporaryDirectory() + "merlin-project-discovery-\(UUID().uuidString)" @@ -239,6 +290,70 @@ struct SyncTests { #expect(!collectMacProjectAgentDiscovery(roots: [root]).servers.contains { $0.name == "docs" }) } + @Test("project discovery visits grandchildren but not linked or deeper directories") + func nestedProjectAgentDiscovery() throws { + let base = NSTemporaryDirectory() + "merlin-nested-discovery-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: base) } + let root = base + "/workspaces" + let nested = root + "/team/repository" + let deeper = nested + "/deeper" + let outside = base + "/outside" + for directory in [nested + "/.cursor", deeper + "/.cursor", outside + "/.cursor"] { + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + } + try #"{"mcpServers":{"nested":{"command":"private-command"}}}"#.write(toFile: nested + "/.cursor/mcp.json", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"too-deep":{"command":"private-command"}}}"#.write(toFile: deeper + "/.cursor/mcp.json", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"linked":{"command":"private-command"}}}"#.write(toFile: outside + "/.cursor/mcp.json", atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(atPath: root + "/linked", withDestinationPath: outside) + try FileManager.default.createSymbolicLink(atPath: root + "/team/linked", withDestinationPath: outside) + + let discovered = collectMacProjectAgentDiscovery(roots: [root]) + #expect(discovered.servers.map(\.name) == ["nested"]) + let payload = String(decoding: try JSONEncoder().encode(discovered.servers), as: UTF8.self) + #expect(!payload.contains("repository") && !payload.contains("private-command")) + } + + @Test("project discovery limits each root to 32 descendant directories") + func projectAgentDiscoveryDirectoryCap() throws { + let root = NSTemporaryDirectory() + "merlin-project-cap-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: root) } + for index in 0..<40 { + let directory = root + "/project-\(String(format: "%02d", index))/.cursor" + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try "{\"mcpServers\":{\"server-\(index)\":{\"command\":\"run\"}}}".write(toFile: directory + "/mcp.json", atomically: true, encoding: .utf8) + } + let discovered = collectMacProjectAgentDiscovery(roots: [root]) + #expect(discovered.servers.count == 32) + } + + @Test("project discovery remains inside the opened root after a path swap") + func projectAgentDiscoveryRootSwap() throws { + let base = NSTemporaryDirectory() + "merlin-project-swap-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: base) } + let root = base + "/approved" + let moved = base + "/original" + let outside = base + "/outside" + for directory in [root + "/team/repo/.cursor", outside + "/team/repo/.cursor", base + "/.cursor/plugins/local/portable"] { + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + } + try #"{"mcpServers":{"inside":{"command":"run"}}}"#.write(toFile: root + "/team/repo/.cursor/mcp.json", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"outside":{"command":"run"}}}"#.write(toFile: outside + "/team/repo/.cursor/mcp.json", atomically: true, encoding: .utf8) + try #"{"name":"portable"}"#.write(toFile: base + "/.cursor/plugins/local/portable/plugin.json", atomically: true, encoding: .utf8) + + let rootFD = try #require(openMacProjectRoot(root)) + try FileManager.default.moveItem(atPath: root, toPath: moved) + try FileManager.default.createSymbolicLink(atPath: root, withDestinationPath: outside) + let discovered = collectMacProjectAgentDiscovery(rootFDs: [rootFD]) + #expect(discovered.servers.map(\.name) == ["inside"]) + #expect(collectMacAgentDiscovery(homes: [base], systemBins: []).assets.contains { + $0.client == "cursor" && $0.kind == "plugin" && $0.name == "portable" && $0.source == ".cursor/plugins/local" + }) + #expect(openMacProjectRoot(root) == nil) + try FileManager.default.createSymbolicLink(atPath: base + "/linked-parent", withDestinationPath: outside) + #expect(openMacProjectRoot(base + "/linked-parent/team/repo") == nil) + #expect(openMacProjectRoot(base + "/outside\0/../approved") == nil) + } + @Test("host id is a 16-char hash, not the raw UUID") func hostId() { let id = syncHostId() diff --git a/merlin/Cargo.toml b/merlin/Cargo.toml index 6b7bd57..1a921bf 100644 --- a/merlin/Cargo.toml +++ b/merlin/Cargo.toml @@ -13,12 +13,13 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" serde_json = "1" +toml = "0.8" sha2 = "0.10" ed25519-dalek = "2" base64 = "0.23" ureq = { version = "3", default-features = false, features = ["rustls"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } -nix = { version = "0.29", features = ["signal"] } +nix = { version = "0.29", features = ["dir", "signal"] } anyhow = "1" libc = "0.2" log = "0.4" diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index 7800725..435935e 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -16,10 +16,15 @@ use std::collections::{BTreeMap, BTreeSet}; use std::ffi::{CStr, CString}; use std::fs; use std::io::Read; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; +mod project_discovery; +use project_discovery::{collect_project_agent_discovery, configured_agent_workspace_roots}; + use anyhow::{Context, Result}; use base64::Engine; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; @@ -1893,202 +1898,6 @@ fn collect_agent_discovery() -> ( discovery } -// MDM supplies a JSON array in the root-owned service configuration. No default -// workspace roots are scanned, and no configured path is sent in inventory. -fn configured_agent_workspace_roots() -> Vec { - let Ok(raw) = std::env::var("MERLIN_AGENT_WORKSPACE_ROOTS") else { - return Vec::new(); - }; - if raw.len() > 4096 { - return Vec::new(); - } - serde_json::from_str::>(&raw) - .unwrap_or_default() - .into_iter() - .filter(|path| path.len() <= 512 && path.starts_with('/')) - .map(PathBuf::from) - .filter(|path| { - path.components().all(|component| { - matches!( - component, - std::path::Component::RootDir | std::path::Component::Normal(_) - ) - }) - }) - .take(8) - .collect() -} - -fn project_directory(path: &std::path::Path) -> bool { - path.symlink_metadata() - .is_ok_and(|meta| meta.is_dir() && !meta.file_type().is_symlink()) -} - -fn collect_project_agent_discovery( - roots: &[PathBuf], -) -> (Vec, Vec) { - const CONFIGS: &[(&str, &str, bool)] = &[ - ("claude", ".mcp.json", false), - ("claude", ".claude/settings.json", false), - ("cursor", ".cursor/mcp.json", false), - ("codex", ".codex/config.toml", true), - ("opencode", ".opencode/opencode.json", false), - ("agents", ".agents/mcp.json", false), - ]; - const ASSETS: &[(&str, &str, &str, &str)] = &[ - ("agents", "skill", ".agents/skills", "skill"), - ("claude", "skill", ".claude/skills", "skill"), - ("claude", "agent", ".claude/agents", "md"), - ("claude", "plugin", ".claude/plugins", "plugin"), - ("codex", "skill", ".codex/skills", "skill"), - ("cursor", "skill", ".cursor/skills", "skill"), - ("maestro", "plugin", ".maestro/plugins", "plugin"), - ("maestro", "plugin", ".composer/plugins", "plugin"), - ]; - let mut servers = BTreeSet::new(); - let mut assets = BTreeSet::new(); - let mut plugin_config_reads = 0; - for root in roots.iter().take(8).filter(|root| project_directory(root)) { - let mut projects = vec![root.clone()]; - if let Ok(entries) = fs::read_dir(root) { - let mut children: Vec<_> = entries - .take(256) - .flatten() - .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) - .map(|entry| entry.path()) - .collect(); - children.sort(); - projects.extend(children.into_iter().take(32)); - } - for project in projects { - if !project_directory(&project) { - continue; - } - for (client, relative, is_toml) in CONFIGS { - let path = project.join(relative); - if path - .parent() - .is_some_and(|parent| parent != project && !project_directory(parent)) - { - continue; - } - let Some(body) = read_agent_config(&path) else { - continue; - }; - assets.insert(DeviceAgentAsset { - client: (*client).into(), - kind: "config".into(), - name: "project".into(), - source: format!("project/{relative}"), - }); - if *relative == ".claude/settings.json" { - if let Ok(value) = serde_json::from_str::(&body) { - if let Some(plugins) = - value.get("enabledPlugins").and_then(|v| v.as_object()) - { - for (name, enabled) in plugins { - if enabled.as_bool() == Some(true) && safe_agent_asset_name(name) { - assets.insert(DeviceAgentAsset { - client: "claude".into(), - kind: "plugin".into(), - name: name.clone(), - source: "project/.claude/settings.json".into(), - }); - } - } - } - } - } - let entries = if *is_toml { - codex_mcp_entries(&body) - } else if *client == "opencode" { - json_mcp_entries(&body, &["mcp"]) - } else { - json_mcp_entries(&body, &["mcpServers", "servers"]) - }; - for (name, transport) in entries - .into_iter() - .filter(|(name, _)| safe_agent_asset_name(name)) - { - servers.insert(DeviceMCPServer { - client: (*client).into(), - name, - source: format!("project/{relative}"), - transport, - }); - } - } - for (client, kind, relative, format) in ASSETS { - let directory = project.join(relative); - if !project_directory(directory.parent().unwrap_or(&project)) - || !project_directory(&directory) - { - continue; - } - let Ok(entries) = fs::read_dir(&directory) else { - continue; - }; - for entry in entries.take(256).flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - let filename = entry.file_name().to_string_lossy().into_owned(); - let name = if *format == "skill" - && file_type.is_dir() - && entry - .path() - .join("SKILL.md") - .symlink_metadata() - .is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) - { - Some(filename.as_str()) - } else if *format == "plugin" && file_type.is_dir() { - Some(filename.as_str()) - } else if *format == "md" && file_type.is_file() { - filename.strip_suffix(".md") - } else { - None - }; - if let Some(name) = name.filter(|name| safe_agent_asset_name(name)) { - assets.insert(DeviceAgentAsset { - client: (*client).into(), - kind: (*kind).into(), - name: name.into(), - source: format!("project/{relative}"), - }); - if *client == "maestro" && *kind == "plugin" { - for config in ["mcp.json", ".mcp.json"] { - if plugin_config_reads >= 32 { - break; - } - plugin_config_reads += 1; - if let Some(body) = read_agent_config(&entry.path().join(config)) { - for (server, transport) in - json_mcp_entries(&body, &["mcpServers", "servers"]) - { - if safe_agent_asset_name(&server) { - servers.insert(DeviceMCPServer { - client: "maestro".into(), - name: server, - source: format!("project/{relative}/*/{config}"), - transport, - }); - } - } - } - } - } - } - } - } - } - } - ( - servers.into_iter().take(128).collect(), - assets.into_iter().take(128).collect(), - ) -} - fn collect_agent_discovery_from( homes: &[PathBuf], system_bins: &[&str], @@ -2120,6 +1929,7 @@ fn collect_agent_discovery_from( let mut servers = BTreeSet::new(); let mut assets = BTreeSet::new(); let mut plugin_config_reads = 0; + let mut cursor_manifest_reads = 0; for name in CLIS { let found = system_bins .iter() @@ -2144,6 +1954,7 @@ fn collect_agent_discovery_from( } } for home in homes.iter().take(65) { + collect_cursor_user_assets(home, &mut assets, &mut cursor_manifest_reads); for (client, kind, relative, extension) in [ ("agents", "skill", ".agents/skills", ""), ("codex", "skill", ".codex/skills", ""), @@ -2267,7 +2078,12 @@ fn collect_agent_discovery_from( } } for (client, relative, is_toml) in CONFIGS { - let Some(body) = read_agent_config(&home.join(relative)) else { + let body = if *client == "codex" { + read_agent_config_beneath(home, relative) + } else { + read_agent_config(&home.join(relative)) + }; + let Some(body) = body else { continue; }; assets.insert(DeviceAgentAsset { @@ -2296,6 +2112,19 @@ fn collect_agent_discovery_from( } continue; } + if *client == "codex" && *relative == ".codex/config.toml" { + for name in codex_enabled_plugin_names(&body) { + if assets.len() >= 128 { + break; + } + assets.insert(DeviceAgentAsset { + client: "codex".into(), + kind: "plugin".into(), + name, + source: ".codex/config.toml".into(), + }); + } + } let entries: Vec<(String, String)> = if *client == "amp" { json_mcp_entries(&body, &["amp.mcpServers"]) } else if *client == "opencode" { @@ -2333,6 +2162,172 @@ fn collect_agent_discovery_from( ) } +/// Open each path component relative to the previous directory. A pathname +/// check followed by `read_dir` would let a user replace a parent with a link +/// while the root collector is traversing it. +fn open_agent_directory(path: &Path) -> Option { + use std::path::Component; + let mut directory = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open("/") + .ok()?; + for component in path.components() { + match component { + Component::RootDir => continue, + Component::Normal(name) => { + directory = open_agent_child_dir(&directory, name.as_bytes())?; + } + _ => return None, + } + } + Some(directory) +} + +fn open_agent_child_dir(parent: &fs::File, name: &[u8]) -> Option { + let name = CString::new(name).ok()?; + // SAFETY: `parent` is open during the call, the name is NUL terminated, + // and ownership of a successful descriptor transfers to File. + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + (fd >= 0).then(|| unsafe { fs::File::from_raw_fd(fd) }) +} + +fn open_agent_regular_file_at(parent: &fs::File, name: &str) -> Option { + let name = CString::new(name).ok()?; + // O_NONBLOCK prevents an attacker from stalling a root collector with a FIFO. + // SAFETY: `parent` is open during the call and `name` is NUL terminated. + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return None; + } + // SAFETY: the successful descriptor is owned solely by this File. + let file = unsafe { fs::File::from_raw_fd(fd) }; + if !file + .metadata() + .ok() + .is_some_and(|meta| meta.is_file() && meta.len() <= 64 << 10) + { + return None; + } + Some(file) +} + +fn read_agent_file_at(parent: &fs::File, name: &str) -> Option { + let file = open_agent_regular_file_at(parent, name)?; + let mut body = String::new(); + file.take((64 << 10) + 1).read_to_string(&mut body).ok()?; + (body.len() <= 64 << 10).then_some(body) +} + +fn read_agent_config_beneath(home: &Path, relative: &str) -> Option { + let (parent, file) = relative.rsplit_once('/')?; + let directory = open_agent_directory(&home.join(parent))?; + read_agent_file_at(&directory, file) +} + +fn read_agent_dir_entries(directory: &fs::File) -> Option { + fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd())).ok() +} + +fn collect_cursor_user_assets( + home: &Path, + assets: &mut BTreeSet, + manifest_reads: &mut usize, +) { + if let Some(directory) = open_agent_directory(&home.join(".cursor/agents")) { + if let Some(entries) = read_agent_dir_entries(&directory) { + for entry in entries.take(256).flatten() { + if assets.len() >= 128 { + break; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let Some(name) = name + .strip_suffix(".md") + .filter(|name| safe_agent_asset_name(name)) + else { + continue; + }; + if open_agent_regular_file_at(&directory, &format!("{name}.md")).is_some() { + assets.insert(DeviceAgentAsset { + client: "cursor".into(), + kind: "agent".into(), + name: name.into(), + source: ".cursor/agents".into(), + }); + } + } + } + } + let Some(directory) = open_agent_directory(&home.join(".cursor/plugins/local")) else { + return; + }; + let Some(entries) = read_agent_dir_entries(&directory) else { + return; + }; + for entry in entries.take(256).flatten() { + if assets.len() >= 128 || *manifest_reads >= 256 { + break; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if !safe_agent_asset_name(&name) { + continue; + } + let Some(plugin_dir) = open_agent_child_dir(&directory, name.as_bytes()) else { + continue; + }; + *manifest_reads += 1; + let manifest = read_agent_file_at(&plugin_dir, "plugin.json").or_else(|| { + let cursor_dir = open_agent_child_dir(&plugin_dir, b".cursor-plugin")?; + read_agent_file_at(&cursor_dir, "plugin.json") + }); + let Some(manifest) = manifest else { + continue; + }; + let valid_name = serde_json::from_str::(&manifest) + .ok() + .and_then(|value| value.get("name")?.as_str().map(str::to_string)) + .is_some_and(|manifest_name| safe_agent_asset_name(&manifest_name)); + if valid_name { + assets.insert(DeviceAgentAsset { + client: "cursor".into(), + kind: "plugin".into(), + name, + source: ".cursor/plugins/local".into(), + }); + } + } +} + +fn codex_enabled_plugin_names(body: &str) -> Vec { + let Ok(value) = body.parse::() else { + return Vec::new(); + }; + value + .get("plugins") + .and_then(toml::Value::as_table) + .into_iter() + .flat_map(|plugins| plugins.iter()) + .filter_map(|(name, config)| { + (safe_agent_asset_name(name) + && config.get("enabled").and_then(toml::Value::as_bool) == Some(true)) + .then(|| name.clone()) + }) + .take(128) + .collect() +} + fn read_agent_config(path: &std::path::Path) -> Option { let file = fs::OpenOptions::new() .read(true) @@ -2735,11 +2730,153 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn user_cursor_and_codex_plugin_probes_validate_files_and_hide_values() { + let root = tmpdir("user-agent-plugins"); + let home = root.join("home"); + fs::create_dir_all(home.join(".cursor/agents")).unwrap(); + fs::write(home.join(".cursor/agents/reviewer.md"), "private prompt").unwrap(); + fs::write(home.join(".cursor/agents/.hidden.md"), "private prompt").unwrap(); + std::os::unix::fs::symlink( + home.join(".cursor/agents/reviewer.md"), + home.join(".cursor/agents/linked.md"), + ) + .unwrap(); + for plugin in [ + "agent-format", + "cursor-format", + "invalid", + "linked", + "oversized", + ] { + fs::create_dir_all(home.join(".cursor/plugins/local").join(plugin)).unwrap(); + } + fs::write( + home.join(".cursor/plugins/local/agent-format/plugin.json"), + r#"{"name":"agent-format","description":"private"}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".cursor/plugins/local/cursor-format/.cursor-plugin")) + .unwrap(); + fs::write( + home.join(".cursor/plugins/local/cursor-format/.cursor-plugin/plugin.json"), + r#"{"name":"cursor-format","url":"https://private.example"}"#, + ) + .unwrap(); + fs::write( + home.join(".cursor/plugins/local/invalid/plugin.json"), + "not json", + ) + .unwrap(); + std::os::unix::fs::symlink( + home.join(".cursor/plugins/local/agent-format/plugin.json"), + home.join(".cursor/plugins/local/linked/plugin.json"), + ) + .unwrap(); + fs::write( + home.join(".cursor/plugins/local/oversized/plugin.json"), + format!( + "{{\"name\":\"oversized\",\"data\":\"{}\"}}", + "x".repeat(65 << 10) + ), + ) + .unwrap(); + fs::create_dir_all(home.join(".codex")).unwrap(); + fs::write( + home.join(".codex/config.toml"), + "[plugins.\"audit@marketplace\"]\nenabled = true\nsecret = 'private'\n[plugins.\"off@marketplace\"]\nenabled = false\n[plugins.\"unset@marketplace\"]\nfoo = true\n[plugins.\"https://private.example/path\"]\nenabled = true\n", + ) + .unwrap(); + let (_, _, assets) = collect_agent_discovery_from(&[home.clone()], &[]); + for (client, kind, name) in [ + ("cursor", "agent", "reviewer"), + ("cursor", "plugin", "agent-format"), + ("cursor", "plugin", "cursor-format"), + ("codex", "plugin", "audit@marketplace"), + ] { + assert!( + assets.iter().any(|asset| asset.client == client + && asset.kind == kind + && asset.name == name), + "missing {client}/{kind}/{name}" + ); + } + for name in [ + ".hidden", + "linked", + "invalid", + "oversized", + "off@marketplace", + "unset@marketplace", + "https://private.example/path", + ] { + assert!( + !assets.iter().any(|asset| asset.name == name), + "unexpected {name}" + ); + } + let output = serde_json::to_string(&assets).unwrap(); + assert!(!output.contains("private")); + assert!(!output.contains("https://")); + assert!(!output.contains("plugin.json")); + + // A symlink in any parent, including the user directory, is rejected. + let link = root.join("linked-home"); + std::os::unix::fs::symlink(&home, &link).unwrap(); + let (_, _, linked_assets) = collect_agent_discovery_from(&[link], &[]); + assert!( + !linked_assets + .iter() + .any(|asset| asset.kind == "plugin" && asset.client == "cursor") + ); + assert!( + !linked_assets + .iter() + .any(|asset| asset.kind == "agent" && asset.client == "cursor") + ); + assert!( + !linked_assets + .iter() + .any(|asset| asset.kind == "plugin" && asset.client == "codex") + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn user_cursor_probe_keeps_asset_and_directory_entry_caps() { + let root = tmpdir("user-agent-cap"); + let home = root.join("home"); + let agents = home.join(".cursor/agents"); + fs::create_dir_all(&agents).unwrap(); + for index in 0..300 { + fs::write(agents.join(format!("agent-{index:03}.md")), "private").unwrap(); + } + let (_, _, assets) = collect_agent_discovery_from(&[home], &[]); + assert!(assets.len() <= 128); + assert!( + assets + .iter() + .all(|asset| asset.client == "cursor" && asset.kind == "agent") + ); + let plugin_home = root.join("plugin-home"); + for index in 0..300 { + let plugin = plugin_home.join(format!(".cursor/plugins/local/plugin-{index:03}")); + fs::create_dir_all(&plugin).unwrap(); + fs::write(plugin.join("plugin.json"), "invalid").unwrap(); + } + let mut plugin_assets = BTreeSet::new(); + let mut manifest_reads = 0; + collect_cursor_user_assets(&plugin_home, &mut plugin_assets, &mut manifest_reads); + assert_eq!(manifest_reads, 256); + assert!(plugin_assets.is_empty()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn project_discovery_is_bounded_and_hides_paths_and_values() { let root = std::env::temp_dir().join(format!("merlin-project-discovery-{}", std::process::id())); - let project = root.join("customer-private"); + let project = root.join("team/customer-private"); fs::create_dir_all(project.join(".cursor")).unwrap(); fs::create_dir_all(project.join(".opencode")).unwrap(); fs::create_dir_all(project.join(".agents")).unwrap(); @@ -2803,6 +2940,7 @@ mod tests { assert!(!assets.iter().any(|item| item.name == "off@marketplace")); let payload = serde_json::to_string(&(servers, assets)).unwrap(); assert!(!payload.contains("customer-private")); + assert!(!payload.contains("team")); assert!(!payload.contains("private-secret")); fs::remove_file(project.join(".cursor/mcp.json")).unwrap(); std::os::unix::fs::symlink( @@ -2816,6 +2954,63 @@ mod tests { .iter() .any(|item| item.name == "docs") ); + let outside = tmpdir("project-outside"); + fs::create_dir_all(outside.join(".cursor")).unwrap(); + fs::write( + outside.join(".cursor/mcp.json"), + r#"{"mcpServers":{"outside":{"command":"ignored"}}}"#, + ) + .unwrap(); + std::os::unix::fs::symlink(&outside, root.join("linked-project")).unwrap(); + std::os::unix::fs::symlink(&project, root.join("linked-team")).unwrap(); + assert!( + !collect_project_agent_discovery(&[root.clone()]) + .0 + .iter() + .any(|item| item.name == "outside") + ); + assert!( + collect_project_agent_discovery(&[root.join("linked-team")]) + .0 + .is_empty() + ); + fs::remove_dir_all(outside).unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn project_discovery_caps_descendants_and_depth() { + let root = tmpdir("project-depth-cap"); + for index in 0..31 { + fs::create_dir_all(root.join(format!("a{index:02}/deep/.cursor"))).unwrap(); + } + fs::write( + root.join("a00/deep/.cursor/mcp.json"), + r#"{"mcpServers":{"within-cap":{"command":"ignored"}}}"#, + ) + .unwrap(); + fs::write( + root.join("a01/deep/.cursor/mcp.json"), + r#"{"mcpServers":{"past-cap":{"command":"ignored"}}}"#, + ) + .unwrap(); + fs::create_dir_all(root.join("a00/deep/too-deep/.cursor")).unwrap(); + fs::write( + root.join("a00/deep/too-deep/.cursor/mcp.json"), + r#"{"mcpServers":{"past-depth":{"command":"ignored"}}}"#, + ) + .unwrap(); + assert_eq!( + project_discovery::project_directory_count( + &root, + Instant::now() + Duration::from_secs(5), + ), + 33 + ); + let (servers, _) = collect_project_agent_discovery(&[root.clone()]); + assert!(servers.iter().any(|item| item.name == "within-cap")); + assert!(!servers.iter().any(|item| item.name == "past-cap")); + assert!(!servers.iter().any(|item| item.name == "past-depth")); fs::remove_dir_all(root).unwrap(); } diff --git a/merlin/src/sync/project_discovery.rs b/merlin/src/sync/project_discovery.rs new file mode 100644 index 0000000..2d44904 --- /dev/null +++ b/merlin/src/sync/project_discovery.rs @@ -0,0 +1,374 @@ +use std::collections::BTreeSet; +use std::ffi::{OsStr, OsString}; +use std::fs::File; +use std::io::Read; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use nix::dir::Dir; +use nix::fcntl::{self, OFlag}; +use nix::sys::stat::Mode; + +use super::{ + DeviceAgentAsset, DeviceMCPServer, codex_mcp_entries, json_mcp_entries, safe_agent_asset_name, +}; + +// MDM supplies a JSON array in the root-owned service configuration. No default +// workspace roots are scanned, and no configured path is sent in inventory. +pub(super) fn configured_agent_workspace_roots() -> Vec { + let Ok(raw) = std::env::var("MERLIN_AGENT_WORKSPACE_ROOTS") else { + return Vec::new(); + }; + if raw.len() > 4096 { + return Vec::new(); + } + serde_json::from_str::>(&raw) + .unwrap_or_default() + .into_iter() + .filter(|path| path.len() <= 512 && path.starts_with('/')) + .map(PathBuf::from) + .filter(|path| { + path.components().all(|component| { + matches!( + component, + std::path::Component::RootDir | std::path::Component::Normal(_) + ) + }) + }) + .take(8) + .collect() +} + +const DIRECTORY_FLAGS: OFlag = OFlag::O_RDONLY + .union(OFlag::O_DIRECTORY) + .union(OFlag::O_NOFOLLOW) + .union(OFlag::O_CLOEXEC); +const FILE_FLAGS: OFlag = OFlag::O_RDONLY + .union(OFlag::O_NOFOLLOW) + .union(OFlag::O_CLOEXEC) + .union(OFlag::O_NONBLOCK); + +fn open_directory_at(parent: &Dir, name: &OsStr) -> Option { + Dir::openat( + Some(parent.as_raw_fd()), + name, + DIRECTORY_FLAGS, + Mode::empty(), + ) + .ok() +} + +fn open_project_root(path: &Path) -> Option { + let mut directory = Dir::open("/", DIRECTORY_FLAGS, Mode::empty()).ok()?; + let mut absolute = false; + for component in path.components() { + match component { + std::path::Component::RootDir => absolute = true, + std::path::Component::Normal(name) if absolute => { + directory = open_directory_at(&directory, name)?; + } + _ => return None, + } + } + absolute.then_some(directory) +} + +fn open_relative_directory(project: &Dir, relative: &str) -> Option { + let mut segments = relative.split('/'); + let first = segments.next()?; + if first.is_empty() || first == "." || first == ".." { + return None; + } + let mut directory = open_directory_at(project, OsStr::new(first))?; + for segment in segments { + if segment.is_empty() || segment == "." || segment == ".." { + return None; + } + directory = open_directory_at(&directory, OsStr::new(segment))?; + } + Some(directory) +} + +fn open_regular_file_at(directory: &Dir, name: &OsStr) -> Option { + let fd = fcntl::openat(Some(directory.as_raw_fd()), name, FILE_FLAGS, Mode::empty()).ok()?; + let file = unsafe { File::from_raw_fd(fd) }; + file.metadata().ok().filter(|meta| meta.is_file())?; + Some(file) +} + +fn read_agent_config_at(project: &Dir, relative: &str) -> Option { + let (parent, filename) = relative.rsplit_once('/').unwrap_or(("", relative)); + let directory = if parent.is_empty() { + None + } else { + Some(open_relative_directory(project, parent)?) + }; + let base = if parent.is_empty() { + project + } else { + directory.as_ref()? + }; + let file = open_regular_file_at(base, OsStr::new(filename))?; + if file.metadata().ok()?.len() > 64 << 10 { + return None; + } + let mut body = String::new(); + file.take((64 << 10) + 1).read_to_string(&mut body).ok()?; + (body.len() <= 64 << 10).then_some(body) +} + +fn first_entries(directory: &mut Dir) -> Vec { + directory + .iter() + .take(258) + .filter_map(Result::ok) + .map(|entry| OsStr::from_bytes(entry.file_name().to_bytes()).to_os_string()) + .filter(|name| name != "." && name != "..") + .take(256) + .collect() +} + +fn project_directories(root: Dir, deadline: Instant) -> Vec { + const MAX_DESCENDANTS: usize = 32; + const MAX_DEPTH: usize = 2; + + let mut projects = vec![(root, 0)]; + let mut next = 0; + // Breadth-first keeps the existing immediate-child coverage when a root + // has 32 or more children. Each child is opened from its parent handle. + while next < projects.len() && projects.len() <= MAX_DESCENDANTS && Instant::now() < deadline { + let remaining = MAX_DESCENDANTS + 1 - projects.len(); + let children = { + let (directory, depth) = &mut projects[next]; + if *depth >= MAX_DEPTH { + Vec::new() + } else { + let mut names = first_entries(directory); + names.sort(); + names + .into_iter() + .filter_map(|name| open_directory_at(directory, &name)) + .take(remaining) + .map(|child| (child, *depth + 1)) + .collect() + } + }; + projects.extend(children); + next += 1; + } + projects + .into_iter() + .map(|(directory, _)| directory) + .collect() +} + +#[cfg(test)] +pub(super) fn project_directory_count(root: &Path, deadline: Instant) -> usize { + open_project_root(root).map_or(0, |dir| project_directories(dir, deadline).len()) +} + +pub(super) fn collect_project_agent_discovery( + roots: &[PathBuf], +) -> (Vec, Vec) { + const CONFIGS: &[(&str, &str, bool)] = &[ + ("claude", ".mcp.json", false), + ("claude", ".claude/settings.json", false), + ("cursor", ".cursor/mcp.json", false), + ("codex", ".codex/config.toml", true), + ("opencode", ".opencode/opencode.json", false), + ("agents", ".agents/mcp.json", false), + ]; + const ASSETS: &[(&str, &str, &str, &str)] = &[ + ("agents", "skill", ".agents/skills", "skill"), + ("claude", "skill", ".claude/skills", "skill"), + ("claude", "agent", ".claude/agents", "md"), + ("claude", "plugin", ".claude/plugins", "plugin"), + ("codex", "skill", ".codex/skills", "skill"), + ("cursor", "skill", ".cursor/skills", "skill"), + ("maestro", "plugin", ".maestro/plugins", "plugin"), + ("maestro", "plugin", ".composer/plugins", "plugin"), + ]; + let mut servers = BTreeSet::new(); + let mut assets = BTreeSet::new(); + let mut plugin_config_reads = 0; + let deadline = Instant::now() + Duration::from_secs(5); + for root in roots.iter().take(8) { + if Instant::now() >= deadline { + break; + } + let Some(root) = open_project_root(root) else { + continue; + }; + for project in project_directories(root, deadline) { + if Instant::now() >= deadline { + break; + } + for (client, relative, is_toml) in CONFIGS { + if Instant::now() >= deadline { + break; + } + let Some(body) = read_agent_config_at(&project, relative) else { + continue; + }; + assets.insert(DeviceAgentAsset { + client: (*client).into(), + kind: "config".into(), + name: "project".into(), + source: format!("project/{relative}"), + }); + if *relative == ".claude/settings.json" { + if let Ok(value) = serde_json::from_str::(&body) { + if let Some(plugins) = + value.get("enabledPlugins").and_then(|v| v.as_object()) + { + for (name, enabled) in plugins { + if enabled.as_bool() == Some(true) && safe_agent_asset_name(name) { + assets.insert(DeviceAgentAsset { + client: "claude".into(), + kind: "plugin".into(), + name: name.clone(), + source: "project/.claude/settings.json".into(), + }); + } + } + } + } + } + let entries = if *is_toml { + codex_mcp_entries(&body) + } else if *client == "opencode" { + json_mcp_entries(&body, &["mcp"]) + } else { + json_mcp_entries(&body, &["mcpServers", "servers"]) + }; + for (name, transport) in entries + .into_iter() + .filter(|(name, _)| safe_agent_asset_name(name)) + { + servers.insert(DeviceMCPServer { + client: (*client).into(), + name, + source: format!("project/{relative}"), + transport, + }); + } + } + for (client, kind, relative, format) in ASSETS { + if Instant::now() >= deadline { + break; + } + let Some(mut directory) = open_relative_directory(&project, relative) else { + continue; + }; + for entry in first_entries(&mut directory) { + if Instant::now() >= deadline { + break; + } + let filename = entry.to_string_lossy().into_owned(); + let child_directory = if *format == "skill" || *format == "plugin" { + open_directory_at(&directory, &entry) + } else { + None + }; + let name = match *format { + "skill" + if child_directory.as_ref().is_some_and(|child| { + open_regular_file_at(child, OsStr::new("SKILL.md")).is_some() + }) => + { + Some(filename.as_str()) + } + "plugin" if child_directory.is_some() => Some(filename.as_str()), + "md" if open_regular_file_at(&directory, &entry).is_some() => { + filename.strip_suffix(".md") + } + _ => None, + }; + if let Some(name) = name.filter(|name| safe_agent_asset_name(name)) { + assets.insert(DeviceAgentAsset { + client: (*client).into(), + kind: (*kind).into(), + name: name.into(), + source: format!("project/{relative}"), + }); + if *client == "maestro" && *kind == "plugin" { + for config in ["mcp.json", ".mcp.json"] { + if plugin_config_reads >= 32 { + break; + } + plugin_config_reads += 1; + if let Some(body) = child_directory + .as_ref() + .and_then(|child| read_agent_config_at(child, config)) + { + for (server, transport) in + json_mcp_entries(&body, &["mcpServers", "servers"]) + { + if safe_agent_asset_name(&server) { + servers.insert(DeviceMCPServer { + client: "maestro".into(), + name: server, + source: format!("project/{relative}/*/{config}"), + transport, + }); + } + } + } + } + } + } + } + } + } + } + ( + servers.into_iter().take(128).collect(), + assets.into_iter().take(128).collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::symlink; + + #[test] + fn held_directory_handles_survive_parent_and_child_symlink_swaps() { + let base = + std::env::temp_dir().join(format!("merlin-project-handle-swap-{}", std::process::id())); + let original = base.join("project"); + let outside = base.join("outside"); + fs::create_dir_all(original.join(".cursor")).unwrap(); + fs::create_dir_all(outside.join(".cursor")).unwrap(); + fs::write(original.join(".cursor/mcp.json"), "inside").unwrap(); + fs::write(outside.join(".cursor/mcp.json"), "outside").unwrap(); + + let parent = open_project_root(&base).unwrap(); + let held_project = open_directory_at(&parent, OsStr::new("project")).unwrap(); + fs::rename(&original, base.join("project-original")).unwrap(); + symlink(&outside, &original).unwrap(); + assert!(open_project_root(&original).is_none()); + assert_eq!( + read_agent_config_at(&held_project, ".cursor/mcp.json").as_deref(), + Some("inside") + ); + + let held_cursor = open_relative_directory(&held_project, ".cursor").unwrap(); + let original_cursor = base.join("project-original/.cursor"); + fs::rename( + &original_cursor, + base.join("project-original/cursor-original"), + ) + .unwrap(); + symlink(outside.join(".cursor"), &original_cursor).unwrap(); + assert!(read_agent_config_at(&held_project, ".cursor/mcp.json").is_none()); + assert_eq!( + read_agent_config_at(&held_cursor, "mcp.json").as_deref(), + Some("inside") + ); + fs::remove_dir_all(base).unwrap(); + } +}