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
8 changes: 6 additions & 2 deletions docs/epics/subagent-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,18 @@ Bring SkillServer's skill artifact model closer to the Cloudflare RFC.
Deliverables:

- Archive artifact generation for skills with resources.
- Archive download endpoint for versioned skills.
- `.zip` archive download endpoint for versioned skills at `/skills/{name}/{version}/archive.zip`.
- RFC index emits `type: "archive"` for resourceful skills.
- Existing per-file resource routes remain available for compatibility.
- Additive archive artifact metadata that preserves existing `SKILL.md` digest behavior.

Acceptance criteria:

- `skill-md` entries digest the raw `SKILL.md` bytes.
- `archive` entries digest the raw archive bytes.
- Archives contain `SKILL.md` at the root.
- Archive paths reject traversal and absolute paths.
- Existing `SKILL.md` download and digest verification behavior remains compatible.
- Existing resource tests keep passing or have explicit compatibility replacements.

### Phase 3: Native Manifest API
Expand Down Expand Up @@ -220,15 +222,17 @@ Add archive artifact support for skills with resources while preserving existing

Tasks:
- [ ] Generate deterministic archives for resourceful skill versions.
- [ ] Add archive download endpoint.
- [ ] Add `.zip` archive download endpoint at `/skills/{name}/{version}/archive.zip`.
- [ ] Emit `type: "archive"` in the RFC feed for resourceful skills.
- [ ] Keep `type: "skill-md"` for single-file skills.
- [ ] Preserve existing resource URLs for compatibility.
- [ ] Preserve existing `SKILL.md` digest behavior by storing archive artifact metadata additively.

Acceptance criteria:
- RFC feed artifact digests verify the exact bytes at `url`.
- Archive entries contain `SKILL.md` at the archive root.
- Path traversal and absolute paths are rejected.
- Existing `SKILL.md` download and digest verification APIs remain compatible.
- Integration tests cover `skill-md` and `archive` entries.
```

Expand Down
1 change: 1 addition & 0 deletions docs/specs/native-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ An identity index lists all versions for one skill or sub-agent.
## Skill Version Detail

Skill version details wrap the exact artifact object that the RFC feed exposes for the same skill version.
Resourceful skill artifacts use SkillServer's canonical `.zip` archive route, `/skills/{skillName}/{version}/archive.zip`.

```json
{
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,10 @@ Target behavior:

- Skills with only `SKILL.md` should publish as `type: "skill-md"`.
- Skills with supporting files should publish as `type: "archive"`.
- SkillServer standardizes archive artifacts on `.zip` at `/skills/{name}/{version}/archive.zip`.
- Archive artifacts should contain `SKILL.md` at the archive root plus supporting files under their relative paths.
- The RFC feed and native manifest should point to the same artifact URL and digest for a given skill version.
- Archive artifact digest and size metadata must be additive. Existing `SKILL.md` digest behavior must remain available for compatibility with current skill download and verification APIs.

Current implementation gap:

Expand Down
96 changes: 85 additions & 11 deletions src/SkillServer/Data/SkillRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ await connection.ExecuteAsync(
"""
SELECT id AS Id, skill_id AS SkillId, version AS Version, description AS Description,
category AS Category, skill_type AS SkillType, sha256 AS Sha256,
size_bytes AS SizeBytes, published_at AS PublishedAt, is_latest AS IsLatest
size_bytes AS SizeBytes,
COALESCE(artifact_sha256, sha256) AS ArtifactSha256,
COALESCE(artifact_size_bytes, size_bytes) AS ArtifactSizeBytes,
published_at AS PublishedAt, is_latest AS IsLatest
FROM skill_versions
WHERE skill_id = @skillId AND is_latest = 1
""",
Expand All @@ -112,7 +115,10 @@ FROM skill_versions
"""
SELECT id AS Id, skill_id AS SkillId, version AS Version, description AS Description,
category AS Category, skill_type AS SkillType, sha256 AS Sha256,
size_bytes AS SizeBytes, published_at AS PublishedAt, is_latest AS IsLatest
size_bytes AS SizeBytes,
COALESCE(artifact_sha256, sha256) AS ArtifactSha256,
COALESCE(artifact_size_bytes, size_bytes) AS ArtifactSizeBytes,
published_at AS PublishedAt, is_latest AS IsLatest
FROM skill_versions
WHERE skill_id = @skillId AND version = @version
""",
Expand All @@ -126,7 +132,10 @@ public async Task<IReadOnlyList<SkillVersion>> GetAllVersionsAsync(long skillId,
"""
SELECT id AS Id, skill_id AS SkillId, version AS Version, description AS Description,
category AS Category, skill_type AS SkillType, sha256 AS Sha256,
size_bytes AS SizeBytes, published_at AS PublishedAt, is_latest AS IsLatest
size_bytes AS SizeBytes,
COALESCE(artifact_sha256, sha256) AS ArtifactSha256,
COALESCE(artifact_size_bytes, size_bytes) AS ArtifactSizeBytes,
published_at AS PublishedAt, is_latest AS IsLatest
FROM skill_versions
WHERE skill_id = @skillId
ORDER BY published_at DESC
Expand All @@ -135,14 +144,37 @@ ORDER BY published_at DESC
return versions.ToList();
}

public async Task<IReadOnlyList<SkillVersion>> GetResourcefulVersionsWithoutArchiveAsync(CancellationToken ct = default)
{
await using var connection = new SqliteConnection(_connectionString);
var versions = await connection.QueryAsync<SkillVersion>(
"""
SELECT sv.id AS Id, sv.skill_id AS SkillId, sv.version AS Version, sv.description AS Description,
sv.category AS Category, sv.skill_type AS SkillType, sv.sha256 AS Sha256,
sv.size_bytes AS SizeBytes,
COALESCE(sv.artifact_sha256, sv.sha256) AS ArtifactSha256,
COALESCE(sv.artifact_size_bytes, sv.size_bytes) AS ArtifactSizeBytes,
sv.published_at AS PublishedAt, sv.is_latest AS IsLatest
FROM skill_versions sv
WHERE sv.skill_type != @archiveType
AND EXISTS (SELECT 1 FROM skill_files sf WHERE sf.skill_version_id = sv.id)
ORDER BY sv.id
""",
new { archiveType = SkillTypes.Archive });
return versions.ToList();
}

public async Task<IReadOnlyList<SkillVersionWithFileCount>> GetAllVersionsWithFileCountAsync(long skillId, CancellationToken ct = default)
{
await using var connection = new SqliteConnection(_connectionString);
var versions = await connection.QueryAsync<SkillVersionWithFileCount>(
"""
SELECT sv.id AS Id, sv.skill_id AS SkillId, sv.version AS Version, sv.description AS Description,
sv.category AS Category, sv.skill_type AS SkillType, sv.sha256 AS Sha256,
sv.size_bytes AS SizeBytes, sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
sv.size_bytes AS SizeBytes,
COALESCE(sv.artifact_sha256, sv.sha256) AS ArtifactSha256,
COALESCE(sv.artifact_size_bytes, sv.size_bytes) AS ArtifactSizeBytes,
sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
(SELECT COUNT(*) FROM skill_files sf WHERE sf.skill_version_id = sv.id) AS FileCount
FROM skill_versions sv
WHERE sv.skill_id = @skillId
Expand All @@ -162,7 +194,10 @@ public async Task<IReadOnlyList<SkillVersionWithMetadata>> GetAllLatestVersionsW
var sql = """
SELECT sv.id AS Id, sv.skill_id AS SkillId, sv.version AS Version, sv.description AS Description,
sv.category AS Category, sv.skill_type AS SkillType, sv.sha256 AS Sha256,
sv.size_bytes AS SizeBytes, sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
sv.size_bytes AS SizeBytes,
COALESCE(sv.artifact_sha256, sv.sha256) AS ArtifactSha256,
COALESCE(sv.artifact_size_bytes, sv.size_bytes) AS ArtifactSizeBytes,
sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
s.name AS SkillName, s.created_at AS SkillCreatedAt, s.updated_at AS SkillUpdatedAt,
(SELECT COUNT(*) FROM skill_versions sv2 WHERE sv2.skill_id = s.id) AS VersionCount
FROM skill_versions sv
Expand Down Expand Up @@ -196,7 +231,9 @@ public async Task<long> CreateVersionAsync(
string skillType,
string sha256,
long sizeBytes,
CancellationToken ct = default)
CancellationToken ct = default,
string? artifactSha256 = null,
long? artifactSizeBytes = null)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync(ct);
Expand All @@ -215,11 +252,23 @@ await connection.ExecuteAsync(

var versionId = await connection.ExecuteScalarAsync<long>(
"""
INSERT INTO skill_versions (skill_id, version, description, category, skill_type, sha256, size_bytes, published_at, is_latest)
VALUES (@skillId, @version, @description, @category, @typeString, @sha256, @sizeBytes, @now, 1);
INSERT INTO skill_versions (skill_id, version, description, category, skill_type, sha256, size_bytes, artifact_sha256, artifact_size_bytes, published_at, is_latest)
VALUES (@skillId, @version, @description, @category, @typeString, @sha256, @sizeBytes, @artifactSha256, @artifactSizeBytes, @now, 1);
SELECT last_insert_rowid();
""",
new { skillId, version, description, category, typeString, sha256, sizeBytes, now },
new
{
skillId,
version,
description,
category,
typeString,
sha256,
sizeBytes,
artifactSha256 = artifactSha256 ?? sha256,
artifactSizeBytes = artifactSizeBytes ?? sizeBytes,
now
},
transaction);

await transaction.CommitAsync(ct);
Expand All @@ -232,6 +281,25 @@ INSERT INTO skill_versions (skill_id, version, description, category, skill_type
}
}

public async Task UpdateVersionArtifactAsync(
long versionId,
string skillType,
string artifactSha256,
long artifactSizeBytes,
CancellationToken ct = default)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.ExecuteAsync(
"""
UPDATE skill_versions
SET skill_type = @skillType,
artifact_sha256 = @artifactSha256,
artifact_size_bytes = @artifactSizeBytes
WHERE id = @versionId
""",
new { versionId, skillType, artifactSha256, artifactSizeBytes });
}

public async Task<IReadOnlyList<SkillFile>> GetFilesAsync(long versionId, CancellationToken ct = default)
{
await using var connection = new SqliteConnection(_connectionString);
Expand Down Expand Up @@ -275,7 +343,10 @@ public async Task<IReadOnlyList<SkillVersionWithMetadata>> SearchSkillsAsync(
var sql = """
SELECT sv.id AS Id, sv.skill_id AS SkillId, sv.version AS Version, sv.description AS Description,
sv.category AS Category, sv.skill_type AS SkillType, sv.sha256 AS Sha256,
sv.size_bytes AS SizeBytes, sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
sv.size_bytes AS SizeBytes,
COALESCE(sv.artifact_sha256, sv.sha256) AS ArtifactSha256,
COALESCE(sv.artifact_size_bytes, sv.size_bytes) AS ArtifactSizeBytes,
sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
s.name AS SkillName, s.created_at AS SkillCreatedAt, s.updated_at AS SkillUpdatedAt,
(SELECT COUNT(*) FROM skill_versions sv2 WHERE sv2.skill_id = s.id) AS VersionCount
FROM skills_fts
Expand Down Expand Up @@ -309,7 +380,10 @@ private static string EscapeFtsToken(string token)
"""
SELECT sv.id AS Id, sv.skill_id AS SkillId, sv.version AS Version, sv.description AS Description,
sv.category AS Category, sv.skill_type AS SkillType, sv.sha256 AS Sha256,
sv.size_bytes AS SizeBytes, sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
sv.size_bytes AS SizeBytes,
COALESCE(sv.artifact_sha256, sv.sha256) AS ArtifactSha256,
COALESCE(sv.artifact_size_bytes, sv.size_bytes) AS ArtifactSizeBytes,
sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
(SELECT COUNT(*) FROM skill_files sf WHERE sf.skill_version_id = sv.id) AS FileCount
FROM skill_versions sv
JOIN skills s ON s.id = sv.skill_id
Expand Down
23 changes: 23 additions & 0 deletions src/SkillServer/Endpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ private static void MapSkillEndpoints(this WebApplication app)
skills.MapGet("/{name}/latest", GetLatestVersion);
skills.MapGet("/{name}/{version}", GetVersion);
skills.MapGet("/{name}/{version}/SKILL.md", DownloadSkillMd);
skills.MapGet("/{name}/{version}/archive.zip", DownloadArchive);
skills.MapGet("/{name}/{version}/{*path}", DownloadResource);
skills.MapPost("/check-updates", CheckUpdates);
skills.MapPost("/", UploadSkill).DisableAntiforgery().AddEndpointFilter<ApiKeyEndpointFilter>();
Expand Down Expand Up @@ -163,6 +164,28 @@ private static async Task<IResult> DownloadSkillMd(
return Results.File(stream, "text/markdown", "SKILL.md");
}

private static async Task<IResult> DownloadArchive(
string name,
string version,
SkillRepository repository,
BlobStorage blobStorage,
CancellationToken ct)
{
var skill = await repository.GetSkillByNameAsync(name, ct);
if (skill is null)
return Results.NotFound();

var skillVersion = await repository.GetVersionAsync(skill.Id, version, ct);
if (skillVersion is null || skillVersion.SkillType != SkillTypes.Archive)
return Results.NotFound();

var stream = blobStorage.GetBlob(skillVersion.ArtifactSha256);
if (stream is null)
return Results.NotFound();

return Results.File(stream, "application/zip", "archive.zip");
}

private static async Task<IResult> DownloadResource(
string name,
string version,
Expand Down
6 changes: 6 additions & 0 deletions src/SkillServer/Models/Skill.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public sealed record SkillVersion
public required string SkillType { get; init; }
public required string Sha256 { get; init; }
public required long SizeBytes { get; init; }
public required string ArtifactSha256 { get; init; }
public required long ArtifactSizeBytes { get; init; }
public required DateTimeOffset PublishedAt { get; init; }
public required bool IsLatest { get; init; }
}
Expand Down Expand Up @@ -58,6 +60,8 @@ public sealed record SkillVersionWithFileCount
public required string SkillType { get; init; }
public required string Sha256 { get; init; }
public required long SizeBytes { get; init; }
public required string ArtifactSha256 { get; init; }
public required long ArtifactSizeBytes { get; init; }
public required DateTimeOffset PublishedAt { get; init; }
public required bool IsLatest { get; init; }
public required int FileCount { get; init; }
Expand All @@ -76,6 +80,8 @@ public sealed record SkillVersionWithMetadata
public required string SkillType { get; init; }
public required string Sha256 { get; init; }
public required long SizeBytes { get; init; }
public required string ArtifactSha256 { get; init; }
public required long ArtifactSizeBytes { get; init; }
public required DateTimeOffset PublishedAt { get; init; }
public required bool IsLatest { get; init; }
public required string SkillName { get; init; }
Expand Down
4 changes: 4 additions & 0 deletions src/SkillServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
builder.Services.AddSingleton<ApiKeyRepository>();
builder.Services.AddSingleton<IndexGenerator>();
builder.Services.AddSingleton<SkillUploadService>();
builder.Services.AddSingleton<SkillArchiveBackfillService>();
builder.Services.AddSingleton<ApiKeyService>();

var app = builder.Build();
Expand All @@ -34,6 +35,9 @@
var dbInitializer = app.Services.GetRequiredService<DatabaseInitializer>();
await dbInitializer.InitializeAsync();

var archiveBackfillService = app.Services.GetRequiredService<SkillArchiveBackfillService>();
await archiveBackfillService.BackfillAsync();

// Seed API key from environment variable
var apiKeyService = app.Services.GetRequiredService<ApiKeyService>();
await apiKeyService.SeedFromEnvironmentAsync();
Expand Down
4 changes: 2 additions & 2 deletions src/SkillServer/Services/IndexGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ public async Task<RfcSkillIndex> GenerateRfcIndexAsync(CancellationToken ct = de
Description = v.Description,
Url = v.SkillType == SkillTypes.SkillMd
? $"{baseUrl}/skills/{v.SkillName}/{v.Version}/SKILL.md"
: $"{baseUrl}/skills/{v.SkillName}/{v.Version}/archive.tar.gz",
Digest = Sha256Digest.Create(v.Sha256).Value,
: $"{baseUrl}/skills/{v.SkillName}/{v.Version}/archive.zip",
Digest = Sha256Digest.Create(v.ArtifactSha256).Value,
Version = v.Version,
Resources = files.Count > 0
? files.Select(f => new RfcResourceEntry
Expand Down
Loading
Loading