feat: add personal skill storage, API, and SDK (#25363)

> Mux updated this PR on behalf of Mike.

## Stack Context

This PR is the storage, permissions, API, and SDK layer for experimental
personal skills. #25362 has landed on `main`, so this branch is
restacked directly on `main`.

Stack order:
1. #25363 storage, permissions, API, and SDK
2. #25365 API test coverage
3. #25366 chattool and chatd integration
4. #25066 settings UI and docs
5. #25386 personal skills slash menu

## What?

Adds the `user_skills` database table, generated queries, RBAC resources
and scopes, audit resource handling, experimental user-scoped CRUD
endpoints, SDK types, and generated API/site types.

Follow-up review and restack fixes:
- Enforce a bounded personal skill description in parser and database
constraints.
- Return `403 Forbidden` for unauthorized create and update attempts.
- Return explicit conflict responses when soft-deleted users are
targeted.
- Keep user admins out of personal skills, while site owners can read
and delete but not create or update.
- Document trigger-raised constraint names and keep schema constants
covered by tests.
- Reuse `UserSkillMetadata` in the full `UserSkill` SDK response type.
- Generate user skill IDs in Go instead of relying on a database
default.
- Rebase on latest `main` and renumber the user skills migration to
`000502_user_skills`.

## Why?

Personal skills need durable user-owned storage with owner
authorization, limited site-owner moderation, and a hidden API surface
before chatd can consume them.

## Validation

- `make gen`
- `go test ./coderd/database -run '^TestUserSkillSchemaConstants$'
-count=1`
- `go test ./coderd/database/dbauthz -run
'^TestMethodTestSuite/TestUserSkills$' -count=1`
- `go test ./coderd -run '^TestPatchUserSkill$' -count=1`
- `go test ./codersdk ./coderd/database/db2sdk`
- `make lint`
- pre-commit hook on `97fd58108d`
This commit is contained in:
Michael Suchacz
2026-05-20 00:09:09 +02:00
committed by GitHub
parent 3c9c8c708d
commit 5a8d0016a5
46 changed files with 2315 additions and 70 deletions
+1 -1
View File
@@ -26,7 +26,7 @@
// and workspace/<name> for the workspace skill. One source must not silently
// override the other.
//
// Site admins can read and modify personal skill content. Personal skills are
// Site admins can read and delete personal skill content. Personal skills are
// user-authored instructions, not secret material. Audit records can include
// raw Markdown content diffs alongside the actor, target user, and relevant
// metadata.
+22 -2
View File
@@ -18,6 +18,14 @@ const MaxPersonalSkillSizeBytes = workspacesdk.MaxSkillMetaBytes
// personal skill upload. Skill names are also used in URL paths.
const MaxPersonalSkillNameBytes = 256
// MaxPersonalSkillDescriptionBytes is the maximum frontmatter description size
// accepted for a personal skill upload.
const MaxPersonalSkillDescriptionBytes = 4096
// MaxPersonalSkillsPerUser is the maximum number of personal skills a user may
// create.
const MaxPersonalSkillsPerUser = 100
// Source identifies where a skill came from.
type Source string
@@ -36,6 +44,8 @@ var (
ErrSkillBodyRequired = xerrors.New("skill body is required")
// ErrSkillTooLarge indicates that the raw skill Markdown is too large.
ErrSkillTooLarge = xerrors.New("skill is too large")
// ErrSkillDescriptionTooLarge indicates that the description is too large.
ErrSkillDescriptionTooLarge = xerrors.New("skill description is too large")
// ErrSkillNotFound indicates that a skill lookup did not match any alias.
ErrSkillNotFound = xerrors.New("skill not found")
// ErrSkillAmbiguous indicates that a skill lookup matched multiple sources.
@@ -65,8 +75,9 @@ type ResolvedSkill struct {
// ParsePersonalSkillMarkdown parses raw personal skill Markdown and enforces
// the personal skill contract. The raw size must not exceed
// MaxPersonalSkillSizeBytes, frontmatter must contain a valid kebab-case name,
// the skill name must not exceed MaxPersonalSkillNameBytes, and the body after
// frontmatter must be non-empty.
// the skill name must not exceed MaxPersonalSkillNameBytes, the description must
// not exceed MaxPersonalSkillDescriptionBytes, and the body after frontmatter
// must be non-empty.
func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) {
if len(raw) > MaxPersonalSkillSizeBytes {
return ParsedSkill{}, xerrors.Errorf(
@@ -102,6 +113,15 @@ func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) {
MaxPersonalSkillNameBytes,
)
}
descriptionBytes := len(description)
if descriptionBytes > MaxPersonalSkillDescriptionBytes {
return ParsedSkill{}, xerrors.Errorf(
"%w: got %d bytes, maximum is %d bytes",
ErrSkillDescriptionTooLarge,
descriptionBytes,
MaxPersonalSkillDescriptionBytes,
)
}
if strings.TrimSpace(body) == "" {
return ParsedSkill{}, xerrors.Errorf(
"%w: skill %q has no content after frontmatter",
+13
View File
@@ -91,6 +91,19 @@ func TestParsePersonalSkillMarkdown(t *testing.T) {
require.ErrorContains(t, err, "maximum is 256 bytes")
})
t.Run("DescriptionTooLong", func(t *testing.T) {
t.Parallel()
_, err := skills.ParsePersonalSkillMarkdown([]byte(personalSkillMarkdownForTest(
"my-skill",
strings.Repeat("a", skills.MaxPersonalSkillDescriptionBytes+1),
"Body.",
)))
require.ErrorIs(t, err, skills.ErrSkillDescriptionTooLarge)
require.ErrorContains(t, err, "maximum is 4096 bytes")
})
t.Run("EmptyBody", func(t *testing.T) {
t.Parallel()