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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions packages/agent-connector/src/adapters/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand All @@ -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}`);
Expand All @@ -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,
Expand All @@ -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}`);
Expand All @@ -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}`);
Expand All @@ -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;
Expand All @@ -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}`);
Expand Down
13 changes: 12 additions & 1 deletion packages/agent-connector/src/workspace-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}.
*/
Expand Down
62 changes: 62 additions & 0 deletions packages/agent-connector/test/skill-installer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(); });
Expand Down
186 changes: 186 additions & 0 deletions workspace/backend/alembic/versions/030_add_skill_registry_mvp.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading