diff --git a/docs/cloud-resource-library-device.md b/docs/cloud-resource-library-device.md index f8e31051..d573f278 100644 --- a/docs/cloud-resource-library-device.md +++ b/docs/cloud-resource-library-device.md @@ -2,9 +2,9 @@ 状态:**开发中,尚未开放远程安装**。NexusDock Cloud 与 AgentDock 都以 `uvwt/agentdock-protocol` 的稳定版本为边界;AgentDock 永远不接受 Cloud Tenant/订阅含义。 -## 可移植 Skill / Plugin 与安全边界 +## Skill / Plugin 备份与安全边界 -- Skill 仍是纯 `SKILL.md` 资源包,沿用现有本地/URL安装、摘要、文件数和 128 MiB 字节限制。Cloud 修订号不写入 Skill frontmatter。 +- Skill 以安装根目录的 `SKILL.md` 作为当前包的 Manifest;子目录中的同名 `SKILL.md` 只是普通资源文件,不额外注册 Skill。沿用现有本地/URL 安装、摘要、文件数和 128 MiB 字节限制。Cloud 修订号不写入 Skill frontmatter。 - Plugin 仍是支持 Portable/OpenAI/Claude 的包,保留 `plugin_manage validate` / `install` / `update` 和内容绑定的 `review_token`;压缩包 64 MiB、提取后 256 MiB。Cloud 修订号不覆盖插件 manifest 原版号;同名非-local 版本不同内容仍拒绝。 - **设备备份保留受管 Skill/Plugin 的完整常规文件**,包括用户提供的配置、依赖、缓存、凭据等内容;不按文件名、目录名、扩展名或内容自动剔除,不进行敏感内容扫描,也不生成此类提示。只沿用原生安装器一致的 ZIP 传输元数据忽略规则,保留路径穿越、符号链接、文件类型和容量安全门禁。Cloud 当前可读取 ZIP,不提供端到端加密。 - 本地检验需要负责 ZIP 路径穿越、符号链接、文件类型、文件数量、解包大小、插件命令/MCP 配置风险;Cloud 只能展示报告和摘要,不能代替设备安装器。 diff --git a/internal/resourcelibrary/export.go b/internal/resourcelibrary/export.go index 6894fe34..a3bb4447 100644 --- a/internal/resourcelibrary/export.go +++ b/internal/resourcelibrary/export.go @@ -9,7 +9,6 @@ import ( "os" "path/filepath" "sort" - "strings" skills "github.com/uvwt/agentdock/internal/skill" ) @@ -17,7 +16,6 @@ import ( // Export 是受管资源的安全备份 ZIP 摘要。本地绝对路径不会出现在结果里。 type Export struct { Files []FileEntry - Warnings []string Size int64 ArchiveDigest string ContentDigest string @@ -42,7 +40,7 @@ func ExportDirectory(root, kind, destination string) (Export, error) { return Export{}, failed("PACKAGE_INVALID", "validation", "managed package must be a regular directory") } - files, err := collectPortableFiles(root, kind) + files, err := collectBackupFiles(root, kind) if err != nil { return Export{}, err } @@ -54,7 +52,7 @@ func ExportDirectory(root, kind, destination string) (Export, error) { } // 摘要按实际文件集计算,原生安装器仅忽略操作系统 ZIP 传输元数据。 - size, archiveDigest, contentDigest, err := writePortableZip(root, files, destination, archiveLimit(kind)) + size, archiveDigest, contentDigest, err := writeBackupZip(root, files, destination, archiveLimit(kind)) if err != nil { return Export{}, err } @@ -73,7 +71,7 @@ func hasFile(files []FileEntry, name string) bool { return false } -func collectPortableFiles(root, kind string) ([]FileEntry, error) { +func collectBackupFiles(root, kind string) ([]FileEntry, error) { fsRoot, err := os.OpenRoot(root) if err != nil { return nil, failed("PACKAGE_INVALID", "validation", "managed package directory cannot be opened") @@ -117,10 +115,6 @@ func collectPortableFiles(root, kind string) ([]FileEntry, error) { if !info.Mode().IsRegular() { return failedf("PACKAGE_INVALID", "validation", "special file is not allowed: %s", relative) } - // Skill 只有根目录 SKILL.md 是 Manifest;嵌套同名文件会让 Cloud/安装器产生歧义。 - if kind == KindSkill && relative != "SKILL.md" && strings.HasSuffix(relative, "/SKILL.md") { - return failedf("PACKAGE_INVALID", "validation", "managed Skill export contains a nested SKILL.md: %s", relative) - } if info.Size() < 0 || total > extractLimit(kind)-info.Size() { return failedf("PACKAGE_INVALID", "validation", "package exceeds %d bytes", extractLimit(kind)) } @@ -141,7 +135,7 @@ func collectPortableFiles(root, kind string) ([]FileEntry, error) { return files, nil } -func writePortableZip(root string, files []FileEntry, destination string, limit int64) (int64, string, string, error) { +func writeBackupZip(root string, files []FileEntry, destination string, limit int64) (int64, string, string, error) { fsRoot, err := os.OpenRoot(root) if err != nil { return 0, "", "", failed("PACKAGE_INVALID", "validation", "managed package directory cannot be opened") diff --git a/internal/resourcelibrary/export_test.go b/internal/resourcelibrary/export_test.go index 678dc98d..65047979 100644 --- a/internal/resourcelibrary/export_test.go +++ b/internal/resourcelibrary/export_test.go @@ -26,9 +26,6 @@ func TestExportDirectory_完整保留配置文件(t *testing.T) { if err != nil { t.Fatal(err) } - if len(exported.Warnings) != 0 { - t.Fatalf("unexpected warnings: %#v", exported.Warnings) - } if len(exported.Files) != 1+len(files) { t.Fatalf("lost files: %#v", exported.Files) } @@ -134,9 +131,6 @@ func TestExportDirectory_源码和依赖目录都保留(t *testing.T) { if err != nil { t.Fatal(err) } - if len(exported.Warnings) != 0 { - t.Fatalf("unexpected warnings: %#v", exported.Warnings) - } if len(exported.Files) != len(files)+1 { t.Fatalf("unexpected files: %#v", exported.Files) } @@ -182,21 +176,29 @@ func TestExportDirectory_敏感文件名不触发额外校验(t *testing.T) { if err != nil { t.Fatal(err) } - if !hasFile(exported.Files, name) || len(exported.Warnings) != 0 { - t.Fatalf("unexpected filtering: %#v", exported) + if !hasFile(exported.Files, name) { + t.Fatalf("file omitted: %#v", exported) } }) } } -func TestExportDirectory_Skill只允许根目录Manifest(t *testing.T) { +func TestExportDirectory_嵌套Skill文档作为普通资源保留(t *testing.T) { root := t.TempDir() - writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Export demo.\n---\n") - writeFile(t, filepath.Join(root, "docs", "SKILL.md"), "---\nname: stale-copy\ndescription: Old manifest.\n---\n") + writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Export demo.\n---\n\n# Demo\n") + writeFile(t, filepath.Join(root, "docs", "SKILL.md"), "---\nname: example-only\ndescription: Example document.\n---\n\n# Example\n") - _, err := ExportDirectory(root, KindSkill, filepath.Join(t.TempDir(), "skill.zip")) - if err == nil || !errorCode(err, "PACKAGE_INVALID") { - t.Fatalf("nested SKILL.md error = %v", err) + archive := filepath.Join(t.TempDir(), "skill.zip") + exported, err := ExportDirectory(root, KindSkill, archive) + if err != nil { + t.Fatal(err) + } + if !hasFile(exported.Files, "docs/SKILL.md") { + t.Fatalf("nested SKILL.md was omitted: %#v", exported.Files) + } + preview, err := validateSkillArchive(t, archive, exported.ArchiveDigest, "demo-skill", exported.ContentDigest) + if err != nil || !preview.Valid { + t.Fatalf("native Skill validator rejected nested resource document: %#v %v", preview, err) } } @@ -211,8 +213,8 @@ func TestExportDirectory_Plugin完整保留配置(t *testing.T) { if err != nil { t.Fatal(err) } - if !hasFile(exported.Files, ".env") || !hasFile(exported.Files, "notes.pem") || len(exported.Warnings) != 0 { - t.Fatalf("plugin filtering: %#v", exported) + if !hasFile(exported.Files, ".env") || !hasFile(exported.Files, "notes.pem") { + t.Fatalf("plugin files lost: %#v", exported.Files) } if err := InspectArchive(archive, KindPlugin); err != nil { t.Fatal(err) diff --git a/internal/resourcelibrary/service.go b/internal/resourcelibrary/service.go index aab2dc24..9fd4f9a4 100644 --- a/internal/resourcelibrary/service.go +++ b/internal/resourcelibrary/service.go @@ -109,7 +109,6 @@ type stagedPackage struct { ReviewToken string Expires time.Time Files []FileEntry - Warnings []string Size int64 } @@ -232,7 +231,7 @@ func (s *Service) exportPrepare(request controlRequest) (map[string]any, error) grant, err := s.remember(stagedPackage{ Purpose: purposeExport, Kind: kind, Name: name, Operation: "export", ArchivePath: destination, ArchiveDigest: exported.ArchiveDigest, PackageDigest: exported.ContentDigest, - Expires: s.deps.Now().Add(ChallengeTTL), Files: exported.Files, Warnings: exported.Warnings, Size: exported.Size, + Expires: s.deps.Now().Add(ChallengeTTL), Files: exported.Files, Size: exported.Size, }) if err != nil { _ = os.Remove(destination) @@ -241,7 +240,7 @@ func (s *Service) exportPrepare(request controlRequest) (map[string]any, error) return map[string]any{ "ok": true, "action": ActionExportPrepare, "kind": kind, "name": name, "archive_digest": exported.ArchiveDigest, "content_digest": exported.ContentDigest, - "size": exported.Size, "files": exported.Files, "warnings": exported.Warnings, + "size": exported.Size, "files": exported.Files, "download_grant": grant, "download_authorized": true, "expires_at": s.pendingExpiry(grant), "transfer": "local_grant_only", "stream_upload_available": true, }, nil diff --git a/internal/resourcelibrary/service_test.go b/internal/resourcelibrary/service_test.go index a9aa5a38..835e56aa 100644 --- a/internal/resourcelibrary/service_test.go +++ b/internal/resourcelibrary/service_test.go @@ -35,6 +35,9 @@ func TestService_导出受管Skill且响应不含本地路径(t *testing.T) { if result["stream_upload_available"] != true || result["transfer"] != "local_grant_only" { t.Fatalf("export transfer = %#v", result) } + if _, exists := result["warnings"]; exists { + t.Fatalf("export response retained obsolete warnings field: %#v", result) + } if strings.Contains(result["archive_digest"].(string), "/") { t.Fatalf("digest leaked a path: %#v", result["archive_digest"]) }