Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/cloud-resource-library-device.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 只能展示报告和摘要,不能代替设备安装器。
Expand Down
14 changes: 4 additions & 10 deletions internal/resourcelibrary/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@ import (
"os"
"path/filepath"
"sort"
"strings"

skills "github.com/uvwt/agentdock/internal/skill"
)

// Export 是受管资源的安全备份 ZIP 摘要。本地绝对路径不会出现在结果里。
type Export struct {
Files []FileEntry
Warnings []string
Size int64
ArchiveDigest string
ContentDigest string
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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")
Expand Down Expand Up @@ -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))
}
Expand All @@ -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")
Expand Down
34 changes: 18 additions & 16 deletions internal/resourcelibrary/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions internal/resourcelibrary/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ type stagedPackage struct {
ReviewToken string
Expires time.Time
Files []FileEntry
Warnings []string
Size int64
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/resourcelibrary/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
Expand Down
Loading