Repository navigation
feat(skills): read a skill's files through the client API - #2192
Conversation
70ab6ac to
962ee59
Compare
peppescg
left a comment
There was a problem hiding this comment.
I ran a panel review on 962ee593 covering spec, standards, test adequacy, security, architecture, reuse and duplication. Path resolution, caller isolation, read limits and dependency direction are all solid, and I found no security blockers. But a few points should be settled before the SDK release. Once #2155 consumes this contract, it can't change.
Please fix before merge
- Skills are listed but cannot be opened when there is no learned-skill store. In
internal/app/build.go:2566,SkillSourceForreturnsnilwhenassets.liveSkills == nil.LiveSkillsfalls back to the static snapshot in the same case.liveSkillsis only built whenLearningStoreURLis set orUserModelDirresolves. A container with no HOME/XDG directory has neither; mecak8s with the default empty learning-store endpoint is one example.- There,
ListSkillsshows the deployment skills, whileListSkillFiles/ReadSkillFilereturnskill_not_foundfor every one of them. - Fix:
return assets.skillSource, which mirrorsregisterSkillFamilyandLiveSkills. Please add a composition test with no learned-skill store; the current one always setsUserModelDir.
- NUL bytes are not refused. The Skill tool's
readAsset(engine/adapter/skillfs/tool.go) rejects data containing NUL as "not textual".ReadSkillFileonly checksutf8.Valid, so a binary that happens to be valid UTF-8 is served as text. That contradicts the SPEC inskill_files.go("a non-text file is refused"). Fix:|| bytes.IndexByte(data, 0) >= 0on theErrSkillFileNotTextcheck, plus a fixture for it. - HTTP 413 for
skill_file_too_large. 413 means the request was too large, and the status fallback inerrorcodes.goalready maps 413 torequest_too_large/InvalidArgument. A client or proxy that branches on status would misread it. Suggest 422, with FailedPrecondition on gRPC. Once released, the status is permanent. - The
SKILL.mdwire name. It permanently names content that is the body without frontmatter. A client offering "download/copy file" would hand out a SKILL.md that discovery rejects, and if the raw file is ever exposed, that name is already taken. Cheap and additive before release: a role field onSkillFileInfo(for examplebool instructions, or a small enum) so clients key off it rather than the string, and state the body-only meaning in the proto comment, not only in Go. - No feature identifier.
features.gosays every additive change, including a new RPC, is announced through features, and that clients never gate on version. The published SDK runs against arbitrary daemons. Against an older one,GET /v1/skills/filesreturns a generic 404 that is easy to confuse withskill_not_found. Either addskill_files, or record the exception explicitly. I know ListSkills and the learned-skill RPCs set no precedent here either. - The 25,000 cap is guarded by the wrong comparison.
TestSkillFileCapMatchesSkillToolCapcomparesskillfs.MaxOutputBytesto the literal25_000, not tomaxSkillFileBytes. Lowering the server constant goes unnoticed. The stated reason for the copy also doesn't hold:internal/adapter/serveralready importsengine/adapter/{memledger,nofs,sessnap,cronparse}, and no depguard rule forbidsskillfs. SuggestmaxSkillFileBytes = skillfs.MaxOutputBytesand dropping the test.
Tests
- No boundary test on the cap. Changing both
> maxSkillFileByteschecks to>=still passes the suite. Please add a 25,000-byte asset that must read successfully next to the 25,001-byte one. - The four error-code strings are never asserted. The tests check HTTP and gRPC statuses only.
skill_not_foundandskill_file_not_foundshare NotFound/404, so the missing-file case would pass with the wrong code. SKILL.mdrefusals are untested. Nothing covers an oversize or non-UTF-8 body, or an empty or frontmatter-only body.- Bob's isolation checks only assert
err != nil. Inskill_files_composition_test.gothey should asserterrors.Is(err, tool.ErrSkillNotFound), so an unrelated failure can't pass as isolation. - Nothing asserts the new RPCs go through the publication preface (
beginSkillRead).
Nits
- The 1,024 truncation silently drops one asset.
FSSourceand the grpcdriver source already enforce the bound and reject an over-limit inventory outright. The server'smaxSkillFileEntries-1cut then drops the last asset of a skill with exactly 1,024, with no signal. Suggest deleting the constant and the truncation. - A physical path can leak (CWE-209). If a skill directory disappears after discovery, the
os.OpenRooterror infssource.goincludes the absolute path, and it reaches the client througherr.Error(). Map it to a fixed message. - The
// DECISION:block inskillfs/atomic.gois misplaced. It sits aboveNewLiveToolForPartitions, so it becomes that function's godoc, but it describesSourceForPartitions. ListSkillskeeps a stale rationale. It staysKindSharedInfrastructurewith "identical for every caller", which this PR's own DECISION says no longer holds.- Correction to the PR description: the engine API exemption applies because
skillfsis an excluded reference adapter inCOMPATIBILITY.md, not because only a method was added.
|
Thanks @peppescg, this was a useful review. Everything below is in Please fix before merge
Tests. Added the exact 25,000-byte boundary (changing Nits. Removed the 1,023-asset truncation (I confirmed One thing left as is. Evidence. Full Fully or partially written by an AI agent. |
Studio's skill page can only show a skill's name and description because the client API stops at ListSkills, although the daemon already reads a skill's body and bundled assets for the Skill tool.
Add two read-only RPCs, ListSkillFiles and ReadSkillFile, with HTTP routes at GET /v1/skills/files and GET /v1/skills/files/content, TypeScript SDK methods skills.listFiles and skills.readFile, four stable error codes, and the reference docs. A listing is SKILL.md (the instruction body) first, then the bundled assets by logical name. Reads go through the caller's own skill view, so a caller's learned skills resolve as body-only, and return text only: oversize and non-text files are refused rather than truncated or repaired. The routes use literal path segments with query parameters because /v1/skills/{name}/files is ambiguous with /v1/skills/learned/{id} in ServeMux.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Resolve the open design assumptions before the first SDK release that carries these RPCs: classify them caller-owned, name the HTTP read route /v1/skills/files/read to match /v1/mcp/resources/read, and record that no feature identifier gates them. Add the tests the SPEC lines were waiting for: the real catalog serves the caller's own view with learned skills body-only, and the real daemon wiring lists and reads only the caller's own learned skill. Document both RPCs and routes in the hand-written API references. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A review found two bugs and several contract points that cannot change once the SDK ships; all were reproduced against a real daemon before fixing. - Without a learned-skill store (no HOME or XDG directory), skills listed but every file read said "not found". Fall back to the same static source ListSkills uses, and test with and without a store. - A file containing a NUL byte but valid UTF-8 was served as text. Refuse it like the Skill tool does. - Map an oversize file to 422 and FailedPrecondition, since 413 already means an oversized request body. - Mark the instruction body with SkillFileInfo.instructions so clients do not key off the name "SKILL.md", which is the body without its frontmatter. - Announce the pair through a skill_files compatibility feature. - Take the 25,000-byte cap from skillfs.MaxOutputBytes and stop truncating a listing at 1,023 assets. - Keep absolute paths out of asset read errors. Tests now cover the cap boundary, the stable error codes, body limits, caller isolation as "not found", and the publication preface. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
18aeda3 to
10428df
Compare
Summary
ListSkills, although the daemon already reads a skill's body and bundled assets for the Skill tool.ListSkillFilesandReadSkillFile, with HTTP routesGET /v1/skills/files?name=andGET /v1/skills/files/read?name=&file=, TypeScript SDK methodsskills.listFilesandskills.readFile, four stable error codes, askill_filescompatibility feature, and the docs.SKILL.md(the instruction body, without its frontmatter, markedinstructions: true) first, then the bundled assets sorted by logical name. Reads go through the caller's own skill view, the same oneListSkillsand the Skill tool use, so a caller's learned skills resolve as body-only.Issue relationship
Relates to #1853
Type of change
Test plan
Baseline checks
task lint)task test): not run separately;task test:raceruns the same suitestask test:race), see the note belowgo run ./cmd/mecademo): tool call, permission ask and approval, resulttask docs)task site:build): not runtask api:check)task api:update+engine/CHANGELOG.md: not needed;engine/adapter/skillfsis a reference adapter excluded from the guarded engine API (engine/COMPATIBILITY.md)task test:racepassed on the final commit except fourmcp/oauthlogintests (TestExactRedirect*). They need the fixed port 18473, which a local Studio dev server was holding; that package is untouched by this PR, and it passes when the port is free (rerun in an isolated network namespace). Everything else passed: 190 packages ok, no failures, no data races.What was tested, and what that does and does not show
Because this PR must be released before the Studio PR can consume it, the evidence below is aimed at one question: will the Studio PR work against the SDK that this PR releases?
Unit and integration tests (in this PR)
instructionsmarker, unknown skill, readingSKILL.mdand an asset, every refusal (invalid name, missing file, oversize, non-text including a NUL byte), the exact 25,000-byte boundary, body limits (oversize, NUL, empty), the HTTP status and the stable errorcodefor each refusal, and that every read goes through the publication preface.engine/adapter/skillfs):SourceForPartitionsserves the caller's own view, shows a learned skill as body-only, and returns not-found for another caller's learned skill.internal/app): a built service lists and reads only the caller's own learned skill (another caller getsErrSkillNotFound), and with no learned-skill store the deployment's skills still list and read, with an explicit-store positive control.>changed to>=at the cap, the NUL check removed, and the old 413 mapping.Review findings, reproduced before they were fixed. A review of the first version found two bugs. Both were reproduced end to end with a real
mecated, the real Studio server, and the Studio page in a browser, then fixed and re-checked the same way.skill "plain-demo": tool: skill not found. It now lists and reads.Against the SDK as it will be published (done locally)
pnpm pack, the same step the release workflow'sprepackruns, and installed that tarball into Studio in place of the published 0.4.0.mecatedbuilt from this branch, with the tarball SDK, using real public skills plus synthetic skills with a binary file, an oversize file, a NUL file, and a 40-file skill. Observed: six files in order with sizes for a real skill; refused files shown with a reason while the readable files in the same skill still loaded; 32 files shown with 8 omitted for the 40-file skill; an unknown skill returned404 skill_not_found;GetCompatibilityInfoadvertisesskill_files./v1/skills/files/readroute. The gRPC path was exercised with an in-repo SDK build, not the tarball.What this does not show
minimumReleaseAgeExcludebefore it installs; that belongs to feat(studio): show a skill's files on its page #2155.ListSkills' publication lock, but there is no load test. The gRPC-driver source's own bound was not checked.Changes
contracts/proto/mecatl/v1/harness.proto,contracts/gen/**ListSkillFiles,ReadSkillFile, their messages, andSkillFileInfo.instructionsinternal/adapter/server/skill_files.gointernal/adapter/server/{grpc,http}.gointernal/adapter/server/{errorcodes,classification,features}.goKindCallerOwned), and theskill_filesfeatureinternal/adapter/server/service.go,internal/app/build.goSkillSourceForseam wired to the caller's live skill view (the static source when there is no learned-skill store);ListSkillsshares its publication prefaceengine/adapter/skillfs/{atomic,fssource}.goSourceForPartitions, a read-onlytool.SkillSourceover one caller's view; asset read errors no longer carry an absolute pathsdk/typescript/**skills.listFilesandskills.readFile, the RPC catalog rows, error codes, API snapshots, and testsuser-docs/reference/**User-facing change
None directly. Clients of the daemon API and the TypeScript SDK gain two read-only methods; Studio uses them in #2155.
Special notes for reviewers
These choices are permanent once released, so they are worth a look. Each is recorded as a
DECISION:inskill_files.gowith the rejected alternative.SKILL.mdnext to its assets, so a client lists and shows one uniform set of files. The engine's own vocabulary is "body" and "assets" with no file concept (ADR 0108); the alternative is aGetSkillthat returns the body plus the asset list and aReadSkillAsset, which makes every client special-case the body.KindCallerOwned, likeListLearnedSkillsandGetLearnedSkill: the boundary resolves the skill through the caller's own view and treats a skill outside it as absent.skill_filesannounces the pair, asfeatures.gorequires for every additive RPC. Against a daemon without it the routes answer a generic 404 that looks likeskill_not_found. The SDK only announces it.SkillFileInfo.instructionsmarks the body; clients should not key off the nameSKILL.md.GET /v1/skills/{name}/fileswould be natural but panics at registration, sinceServeMuxfinds it ambiguous withGET /v1/skills/learned/{id}. The routes use a literalfilessegment and query parameters, like/v1/mcp/resources/read.SKILL.mdis the body only (the text after the frontmatter, not the raw file).tool.SkillSourcehas no accessor for the raw file, so the frontmatter is not included and the listed size is the body's size.422/FailedPreconditionwith different stable codes;413is not used because it already means an oversized request body here.ListSkills' classification text now notes that the caller's learned skills are added to its view. Its kind (KindSharedInfrastructure) is left to a separate change.Fully or partially written by an AI agent.
🤖 Generated with Claude Code