diff --git a/backend/__tests__/service/skills.test.js b/backend/__tests__/service/skills.test.js index 26aa31e5cc..918c783da3 100644 --- a/backend/__tests__/service/skills.test.js +++ b/backend/__tests__/service/skills.test.js @@ -3,13 +3,25 @@ const request = require('supertest'); const jwt = require('jsonwebtoken'); const Pod = require('../../models/Pod'); const PodAsset = require('../../models/PodAsset'); +const PodAssetService = require('../../services/podAssetService').default; const User = require('../../models/User'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const AgentIdentityService = require('../../services/agentIdentityService'); +const { issueRuntimeTokenForAgent } = require('../../routes/registry/tokens'); +const agentsRuntimeRoutes = require('../../routes/agentsRuntime'); const skillsRoutes = require('../../routes/skills'); const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); const JWT_SECRET = 'test-jwt-secret'; describe('Skills Catalog Routes', () => { + test('normalizes long punctuation runs without changing scoped skill keys', () => { + expect(PodAssetService.normalizeSkillKey(`${'-'.repeat(100_000)}Focus---Mode${'-'.repeat(100_000)}`)) + .toBe('focus-mode'); + expect(PodAssetService.buildScopedSkillKey({ name: 'Focus Mode', scope: 'agent', agentName: 'Scout', instanceId: 'default' })) + .toBe('focus-mode-scout-default'); + }); + let app; let authToken; let userId; @@ -21,6 +33,7 @@ describe('Skills Catalog Routes', () => { app = express(); app.use(express.json()); app.use('/api/skills', skillsRoutes); + app.use('/api/agents/runtime', agentsRuntimeRoutes); const user = await User.create({ username: 'skilluser', @@ -81,4 +94,85 @@ describe('Skills Catalog Routes', () => { expect(asset).toBeTruthy(); expect(asset.metadata.license).toBe('MIT'); }); + + test('only the installer or instance admin can add and remove agent skills', async () => { + const member = await User.create({ username: 'skillmember', email: 'skillmember@example.com', password: 'password123' }); + await Pod.updateOne({ _id: podId }, { $push: { members: member._id } }); + const memberToken = jwt.sign({ id: member._id.toString() }, JWT_SECRET); + await AgentInstallation.create({ agentName: 'scout', instanceId: 'default', podId, version: '1.0.0', installedBy: userId }); + const body = { podId, name: 'Review', content: '# Review', scope: 'agent', agentName: 'scout', instanceId: 'default' }; + + const deniedAdd = await request(app).post('/api/skills/import').set('Authorization', `Bearer ${memberToken}`).send(body); + expect(deniedAdd.status).toBe(403); + expect(await PodAsset.countDocuments({ podId, type: 'skill', 'metadata.skillName': 'Review' })).toBe(0); + + const admin = await User.create({ username: 'skilladmin', email: 'skilladmin@example.com', password: 'password123', role: 'admin' }); + await Pod.updateOne({ _id: podId }, { $push: { members: admin._id } }); + const adminToken = jwt.sign({ id: admin._id.toString() }, JWT_SECRET); + const adminAdd = await request(app).post('/api/skills/import').set('Authorization', `Bearer ${adminToken}`) + .send({ ...body, name: 'Admin review' }); + expect(adminAdd.status).toBe(201); + + const added = await request(app).post('/api/skills/import').set('Authorization', `Bearer ${authToken}`).send(body); + expect(added.status).toBe(201); + const url = `/api/skills/pods/${podId}/imported?scope=agent&agentName=scout&instanceId=default&name=Review`; + const deniedRemove = await request(app).delete(url).set('Authorization', `Bearer ${memberToken}`); + expect(deniedRemove.status).toBe(403); + expect(await PodAsset.countDocuments({ podId, status: 'active', 'metadata.skillName': 'Review' })).toBe(1); + + const removed = await request(app).delete(url).set('Authorization', `Bearer ${authToken}`); + expect(removed.status).toBe(200); + expect(await PodAsset.countDocuments({ podId, status: 'active', 'metadata.skillName': 'Review' })).toBe(0); + }); + + test('records mounted names for the authenticated agent and returns them to the page', async () => { + await request(app).post('/api/skills/import').set('Authorization', `Bearer ${authToken}`) + .send({ podId, name: 'review', content: '# Review', scope: 'agent', agentName: 'scout', instanceId: 'default' }); + await request(app).post('/api/skills/import').set('Authorization', `Bearer ${authToken}`) + .send({ podId, name: 'local-only', content: '# Local', metadataOnly: true, scope: 'agent', agentName: 'scout', instanceId: 'default' }); + const installation = await AgentInstallation.findOne({ podId, agentName: 'scout' }); + const agentUser = await AgentIdentityService.getOrCreateAgentUser('scout', { instanceId: 'default' }); + const { token } = await issueRuntimeTokenForAgent(agentUser, 'test skill mount', installation); + const feed = await request(app).get(`/api/agents/runtime/pods/${podId}/skills`) + .set('Authorization', `Bearer ${token}`); + expect(feed.status).toBe(200); + expect(feed.body.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'review', content: '# Review' }), + ])); + expect(feed.body.items.map((item) => item.name)).not.toContain('local-only'); + const path = `/api/agents/runtime/pods/${podId}/skill-load`; + const posted = await request(app).post(path).set('Authorization', `Bearer ${token}`) + .send({ mounted: ['review', 'private-operator-skill'], conflicted: ['draft'] }); + expect(posted.status).toBe(200); + const installAfter = await AgentInstallation.findById(installation._id).lean(); + expect(installAfter.skillLoad).toMatchObject({ mounted: ['review', 'private-operator-skill'], conflicted: ['draft'] }); + expect(installAfter.skillLoad.at).toBeInstanceOf(Date); + + const read = await request(app).get(`/api/skills/pods/${podId}/imported?scope=agent&agentName=scout&instanceId=default`) + .set('Authorization', `Bearer ${authToken}`); + expect(read.status).toBe(200); + expect(read.body.skillLoad).toMatchObject({ mounted: ['review'], conflicted: [] }); + expect(JSON.stringify(read.body)).not.toContain('private-operator-skill'); + expect(read.body.canEdit).toBe(true); + + const unavailable = await request(app).post(path).set('Authorization', `Bearer ${token}`) + .send({ mounted: [], conflicted: [], status: 'unavailable' }); + expect(unavailable.status).toBe(200); + const unavailableRead = await request(app).get(`/api/skills/pods/${podId}/imported?scope=agent&agentName=scout&instanceId=default`) + .set('Authorization', `Bearer ${authToken}`); + expect(unavailableRead.body.skillLoad).toMatchObject({ status: 'unavailable', mounted: [], conflicted: [] }); + + const invalid = await request(app).post(path).set('Authorization', `Bearer ${token}`) + .send({ mounted: ['/private/skill'], conflicted: [] }); + expect(invalid.status).toBe(400); + expect((await AgentInstallation.findById(installation._id).lean()).skillLoad.status) + .toBe('unavailable'); + + await request(app).delete(`/api/skills/pods/${podId}/imported?scope=agent&agentName=scout&instanceId=default&name=review`) + .set('Authorization', `Bearer ${authToken}`); + const afterRemove = await request(app).get(`/api/agents/runtime/pods/${podId}/skills`) + .set('Authorization', `Bearer ${token}`); + expect(afterRemove.status).toBe(200); + expect(afterRemove.body.items.map((item) => item.name)).not.toContain('review'); + }); }); diff --git a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json index 829c09e8f5..f3cdee71d3 100644 --- a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json +++ b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json @@ -163,7 +163,6 @@ "routes/skills.ts GET /:skillId/ratings/summary [unlimited]", "routes/skills.ts GET /catalog [unlimited]", "routes/skills.ts GET /gateway-credentials [unlimited]", - "routes/skills.ts GET /pods/:podId/imported [unlimited]", "routes/skills.ts GET /ratings/summary [unlimited]", "routes/skills.ts GET /requirements [unlimited]", "routes/skills.ts PATCH /gateway-credentials [unlimited]", diff --git a/backend/__tests__/unit/routes/skills.import-source-guard.test.js b/backend/__tests__/unit/routes/skills.import-source-guard.test.js index 9bc3839458..0e569d6d31 100644 --- a/backend/__tests__/unit/routes/skills.import-source-guard.test.js +++ b/backend/__tests__/unit/routes/skills.import-source-guard.test.js @@ -38,7 +38,8 @@ const GITHUB_SOURCE = 'https://github.com/acme/skills/blob/main/skills/notes/SKI const RAW_SOURCE = 'https://raw.githubusercontent.com/acme/skills/main/skills/notes/SKILL.md'; const LISTING_URL = 'https://api.github.com/repos/acme/skills/contents/skills/notes?ref=main'; -const importBody = (overrides) => ({ podId: 'pod-1', name: 'notes', ...overrides }); +const POD_ID = '507f1f77bcf86cd799439011'; +const importBody = (overrides) => ({ podId: POD_ID, name: 'notes', ...overrides }); describe('skill source fetches are confined to GitHub', () => { let fetchSpy; @@ -46,7 +47,7 @@ describe('skill source fetches are confined to GitHub', () => { let routes; beforeEach(() => { - const pod = { _id: 'pod-1', createdBy: 'user-1', members: [] }; + const pod = { _id: POD_ID, createdBy: 'user-1', members: [] }; Pod.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue(pod) }); AgentInstallation.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }); PodAssetService.upsertImportedSkillAsset.mockResolvedValue({ _id: 'asset-1' }); diff --git a/backend/__tests__/unit/routes/skills.source-unavailable.test.js b/backend/__tests__/unit/routes/skills.source-unavailable.test.js index 6a13a0c4a0..65dc0125b5 100644 --- a/backend/__tests__/unit/routes/skills.source-unavailable.test.js +++ b/backend/__tests__/unit/routes/skills.source-unavailable.test.js @@ -43,7 +43,8 @@ const GITHUB_SOURCE = 'https://github.com/acme/skills/blob/main/skills/notes/SKI const RAW_SOURCE = 'https://raw.githubusercontent.com/acme/skills/main/skills/notes/SKILL.md'; const LISTING_URL = 'https://api.github.com/repos/acme/skills/contents/skills/notes?ref=main'; -const importBody = (overrides) => ({ podId: 'pod-1', name: 'notes', ...overrides }); +const POD_ID = '507f1f77bcf86cd799439011'; +const importBody = (overrides) => ({ podId: POD_ID, name: 'notes', ...overrides }); const LEGACY_SOURCE = 'https://github.com/openclaw/skills/tree/main/skills/acme/notes/SKILL.md'; const CLAWHUB_META = 'https://clawhub.ai/api/skill?owner=acme&slug=notes'; @@ -63,7 +64,7 @@ describe('a catalog source without the skill is a 404 or a 502, never a 500', () let routes; beforeEach(() => { - const pod = { _id: 'pod-1', createdBy: 'user-1', members: [] }; + const pod = { _id: POD_ID, createdBy: 'user-1', members: [] }; Pod.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue(pod) }); AgentInstallation.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }); PodAssetService.upsertImportedSkillAsset.mockReset(); diff --git a/backend/models/AgentRegistry.ts b/backend/models/AgentRegistry.ts index b3829a4d08..d0de075984 100644 --- a/backend/models/AgentRegistry.ts +++ b/backend/models/AgentRegistry.ts @@ -211,6 +211,7 @@ export interface IAgentInstallationRegistry extends Document { errorMessage?: string; staleSince?: Date; installedBy: Types.ObjectId; + skillLoad?: { at: Date; mounted: string[]; conflicted: string[]; status?: 'available' | 'unavailable' }; usage: { lastUsedAt?: Date; totalCalls: number; @@ -248,6 +249,10 @@ const AgentInstallationSchema = new Schema( errorMessage: String, staleSince: { type: Date }, installedBy: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + skillLoad: { + type: new Schema({ at: Date, mounted: [String], conflicted: [String], status: { type: String, enum: ['available', 'unavailable'] } }, { _id: false }), + default: undefined, + }, usage: { lastUsedAt: Date, totalCalls: { type: Number, default: 0 }, diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 47943f758e..d2147f9f7d 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -35,6 +35,7 @@ const Activity = require('../models/Activity'); const User = require('../models/User'); const Post = require('../models/Post'); const Pod = require('../models/Pod'); +const PodAsset = require('../models/PodAsset'); const { AgentInstallation } = require('../models/AgentRegistry'); const File = require('../models/File'); const { getObjectStore } = require('../services/objectStore'); @@ -1593,6 +1594,63 @@ router.post( }, ); +// Active catalog skills are delivered at turn start so an add or remove takes +// effect on the next spawn without rewriting the operator's local environment. +router.get('/pods/:podId/skills', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { + try { + const { podId } = req.params; + const installation = resolveInstallationForPod(req.agentInstallations, req.agentInstallation, podId); + if (!installation || String(installation.podId) !== podId + || !ensurePodMatch(req.agentInstallations || installation, podId, req.agentAuthorizedPodIds)) { + return res.status(403).json({ message: 'Agent token not authorized for this pod' }); + } + const assets = await PodAsset.find({ podId, type: 'skill', status: 'active', 'metadata.scope': 'agent' }) + .select('content metadata').lean(); + const items = assets.filter((asset: any) => { + const meta = asset.metadata || {}; + return String(meta.agentName || '').toLowerCase() === String(installation.agentName).toLowerCase() + && String(meta.instanceId || 'default').toLowerCase() === String(installation.instanceId || 'default').toLowerCase() + && meta.metadataOnly !== true + && !String(asset.content || '').includes('Imported as metadata from a local agent'); + }).map((asset: any) => ({ + name: String(asset.metadata.skillName || ''), + content: String(asset.content || ''), + })).filter((item: { name: string; content: string }) => item.name && item.content); + return res.json({ items }); + } catch (error) { + console.error('Error loading agent skills:', error); + return res.status(500).json({ message: 'Failed to load agent skills' }); + } +}); + +// The wrapper reports what its adapter actually mounted for this spawn. Keep +// only skill names: local source paths belong to the operator's machine. +router.post('/pods/:podId/skill-load', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { + try { + const { podId } = req.params; + const installation = resolveInstallationForPod(req.agentInstallations, req.agentInstallation, podId); + if (!installation || String(installation.podId) !== podId + || !ensurePodMatch(req.agentInstallations || installation, podId, req.agentAuthorizedPodIds)) { + return res.status(403).json({ message: 'Agent token not authorized for this pod' }); + } + const validNames = (value: unknown): value is string[] => Array.isArray(value) + && value.length <= 50 + && value.every((name) => typeof name === 'string' && name.length > 0 && name.length <= 120 && !/[\\/]/.test(name)); + const { mounted, conflicted, status = 'available' } = req.body || {}; + if (!validNames(mounted) || !validNames(conflicted) || !['available', 'unavailable'].includes(status)) { + return res.status(400).json({ message: 'mounted and conflicted must be arrays of skill names, with a valid status' }); + } + await AgentInstallation.updateOne( + { _id: installation._id, status: 'active' }, + { $set: { skillLoad: { at: new Date(), mounted, conflicted, status } } }, + ); + return res.json({ success: true }); + } catch (error) { + console.error('Error recording skill load:', error); + return res.status(500).json({ message: 'Failed to record skill load' }); + } +}); + router.get('/pods/:podId/context', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { podId } = req.params; diff --git a/backend/routes/skills.ts b/backend/routes/skills.ts index 35ad4e4a05..d5b3b66fba 100644 --- a/backend/routes/skills.ts +++ b/backend/routes/skills.ts @@ -1,6 +1,12 @@ // eslint-disable-next-line global-require const express = require('express'); // eslint-disable-next-line global-require +const mongoose = require('mongoose'); +// eslint-disable-next-line global-require +const { rateLimit } = require('express-rate-limit'); +// eslint-disable-next-line global-require +const { cloudflareIpRateLimitKeyGenerator } = require('../middleware/ipRateLimit'); +// eslint-disable-next-line global-require const JSON5 = require('json5'); // eslint-disable-next-line global-require const auth = require('../middleware/auth'); @@ -15,6 +21,8 @@ const Gateway = require('../models/Gateway'); // eslint-disable-next-line global-require const { AgentInstallation } = require('../models/AgentRegistry'); // eslint-disable-next-line global-require +const User = require('../models/User'); +// eslint-disable-next-line global-require const PodAssetService = require('../services/podAssetService'); // eslint-disable-next-line global-require const SkillsCatalogService = require('../services/skillsCatalogService'); @@ -55,6 +63,15 @@ interface PodError extends Error { } const router: ReturnType = express.Router(); +const importedSkillsReadLimit = rateLimit({ + windowMs: 60_000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + skip: () => process.env.NODE_ENV === 'test', + keyGenerator: (req: unknown) => cloudflareIpRateLimitKeyGenerator(req), + handler: (_req: unknown, res: Res) => res.status(429).json({ code: 'rate_limited' }), +}); const getUserId = (req: AuthReq): string | undefined => req.user?.id || req.userId; @@ -102,7 +119,8 @@ const extractCredentialHints = (content: string | undefined, metadataOverride?: }; const ensurePodAccess = async (podId: string, userId: unknown) => { - const pod = await Pod.findById(podId).lean() as Record & { createdBy?: { toString: () => string }; members?: Array<{ userId?: { toString: () => string }; toString: () => string }> } | null; + if (!mongoose.Types.ObjectId.isValid(podId)) { const e = new Error('Pod not found') as PodError; e.code = 'POD_NOT_FOUND'; throw e; } + const pod = await Pod.findById(new mongoose.Types.ObjectId(podId)).lean() as Record & { createdBy?: { toString: () => string }; members?: Array<{ userId?: { toString: () => string }; toString: () => string }> } | null; if (!pod) { const e = new Error('Pod not found') as PodError; e.code = 'POD_NOT_FOUND'; throw e; } const userIdStr = userId?.toString(); const isCreator = pod.createdBy?.toString() === userIdStr; @@ -111,6 +129,26 @@ const ensurePodAccess = async (podId: string, userId: unknown) => { return pod; }; +const ensureAgentSkillEditor = async (podId: string, agentName: string, instanceId: string, userId: unknown) => { + const installations = await AgentInstallation.find({ + podId: new mongoose.Types.ObjectId(podId), status: 'active', + }).select('agentName instanceId installedBy').lean() as Array<{ agentName: string; instanceId: string; installedBy?: { toString: () => string } }>; + const installation = installations.find((item) => item.agentName === agentName.toLowerCase() && item.instanceId === instanceId); + if (!installation) { const e = new Error('Agent not installed in this pod') as PodError; e.code = 'AGENT_NOT_FOUND'; throw e; } + if (installation.installedBy?.toString() === userId?.toString()) return; + const user = await User.findById(userId).select('role').lean() as { role?: string } | null; + if (user?.role === 'admin') return; + const e = new Error('Only the agent installer or an instance admin can change its skills') as PodError; + e.code = 'INSTALLER_ONLY'; + throw e; +}; + +const skillWriteStatus = (error: PodError): number => { + if (error.code === 'INSTALLER_ONLY' || error.code === 'POD_ACCESS_DENIED') return 403; + if (error.code === 'AGENT_NOT_FOUND' || error.code === 'POD_NOT_FOUND') return 404; + return 500; +}; + const ensureDefaultGateway = async (userId: unknown) => { const existing = await Gateway.findOne({ slug: 'default' }); if (existing) return existing; @@ -119,7 +157,8 @@ const ensureDefaultGateway = async (userId: unknown) => { }; const syncOpenClawInstallationsForPodSkillChange = async ({ podId }: { podId: string }) => { - const installations = await AgentInstallation.find({ podId, agentName: 'openclaw', status: 'active' }).lean() as Array>; + const installations = (await AgentInstallation.find({ podId: new mongoose.Types.ObjectId(podId), status: 'active' }).lean() as Array>) + .filter((installation) => installation.agentName === 'openclaw'); if (!installations.length) return { attempted: 0, synced: 0, failed: 0, items: [] }; const items = await Promise.all(installations.map(async (installation) => { const instanceId = String(installation.instanceId || 'default').trim() || 'default'; @@ -247,7 +286,7 @@ router.get('/requirements', auth, async (req: AuthReq, res: Res) => { } }); -router.get('/pods/:podId/imported', auth, async (req: AuthReq, res: Res) => { +router.get('/pods/:podId/imported', importedSkillsReadLimit, auth, async (req: AuthReq, res: Res) => { try { const userId = getUserId(req); const { podId } = req.params || {}; @@ -270,7 +309,27 @@ router.get('/pods/:podId/imported', auth, async (req: AuthReq, res: Res) => { description: (asset?.metadata as Record)?.description || null, license: (asset?.metadata as Record)?.license || null, })); - return res.json({ items }); + const installation = scope === 'agent' + ? await AgentInstallation.findOne({ podId, agentName: agentName.toLowerCase(), instanceId, status: 'active' }) + .select('installedBy skillLoad').lean() as { + installedBy?: { toString: () => string }; + skillLoad?: { at?: Date; mounted?: string[]; conflicted?: string[]; status?: string }; + } | null + : null; + if (scope === 'agent' && !installation) return res.status(404).json({ error: 'Agent not installed in this pod' }); + const user = scope === 'agent' ? await User.findById(userId).select('role').lean() as { role?: string } | null : null; + const canEdit = scope === 'agent' && Boolean(installation && ( + installation.installedBy?.toString() === userId?.toString() || user?.role === 'admin' + )); + const installedNames = new Set(items.map((item) => String(item.name || '').toLowerCase())); + const load = installation?.skillLoad; + const skillLoad = load?.at ? { + at: load.at, + status: load.status || 'available', + mounted: (load.mounted || []).filter((name) => installedNames.has(name.toLowerCase())), + conflicted: (load.conflicted || []).filter((name) => installedNames.has(name.toLowerCase())), + } : null; + return res.json({ items, canEdit, skillLoad }); } catch (error) { console.error('Error loading imported skills:', error); return res.status(500).json({ error: 'Failed to load imported skills' }); @@ -288,6 +347,7 @@ router.delete('/pods/:podId/imported', auth, async (req: AuthReq, res: Res) => { if (!name) return res.status(400).json({ error: 'name is required' }); await ensurePodAccess(podId || '', userId); if (scope === 'agent' && (!agentName || !instanceId)) return res.status(400).json({ error: 'agentName and instanceId are required for agent scope.' }); + if (scope === 'agent') await ensureAgentSkillEditor(podId || '', agentName, instanceId, userId); const skillKey = PodAssetService.buildScopedSkillKey({ name, scope, agentName: scope === 'agent' ? agentName : undefined, instanceId: scope === 'agent' ? instanceId : undefined }); const asset = await PodAsset.findOneAndUpdate({ podId, type: 'skill', status: 'active', 'metadata.skillKey': skillKey }, { $set: { status: 'archived' } }, { new: true }) as { _id: unknown } | null; if (!asset) return res.status(404).json({ error: 'Skill not found' }); @@ -295,16 +355,20 @@ router.delete('/pods/:podId/imported', auth, async (req: AuthReq, res: Res) => { return res.json({ success: true, assetId: asset._id, sync }); } catch (error) { console.error('Error uninstalling skill:', error); - return res.status(500).json({ error: 'Failed to uninstall skill' }); + return res.status(skillWriteStatus(error as PodError)).json({ error: (error as Error).message }); } }); router.post('/import', auth, async (req: AuthReq, res: Res) => { try { const userId = getUserId(req); - const { podId, name, content, tags = [], sourceUrl, license, scope = 'pod', agentName, instanceId, description } = (req.body || {}) as { podId?: string; name?: string; content?: string; tags?: string[]; sourceUrl?: string; license?: string; scope?: string; agentName?: string; instanceId?: string; description?: string }; + const { podId, name, content, tags = [], sourceUrl, license, scope = 'pod', agentName, instanceId, description, metadataOnly = false } = (req.body || {}) as { podId?: string; name?: string; content?: string; tags?: string[]; sourceUrl?: string; license?: string; scope?: string; agentName?: string; instanceId?: string; description?: string; metadataOnly?: boolean }; if (!podId || !name) return res.status(400).json({ error: 'podId and name are required' }); await ensurePodAccess(podId, userId); + if (scope === 'agent') { + if (!agentName || !instanceId) return res.status(400).json({ error: 'agentName and instanceId are required for agent scope.' }); + await ensureAgentSkillEditor(podId, agentName, instanceId, userId); + } let skillContent = content; let resolvedSourceUrl = sourceUrl; let extraFiles: unknown[] = []; @@ -326,7 +390,7 @@ router.post('/import', auth, async (req: AuthReq, res: Res) => { } if (!skillContent) return res.status(400).json({ error: 'Skill content is required (provide content or a SKILL.md source URL).' }); const normalizedScope = scope === 'agent' ? 'agent' : 'pod'; - const metadata = { scope: normalizedScope, agentName: normalizedScope === 'agent' ? agentName : undefined, instanceId: normalizedScope === 'agent' ? instanceId : undefined, sourceUrl: resolvedSourceUrl || null, license: license || null, description: description || null, importedAt: new Date().toISOString(), tags, extraFiles }; + const metadata = { scope: normalizedScope, agentName: normalizedScope === 'agent' ? agentName : undefined, instanceId: normalizedScope === 'agent' ? instanceId : undefined, sourceUrl: resolvedSourceUrl || null, license: license || null, description: description || null, metadataOnly: metadataOnly === true, importedAt: new Date().toISOString(), tags, extraFiles }; const asset = await PodAssetService.upsertImportedSkillAsset({ podId, name, markdown: skillContent, tags, metadata, createdBy: userId }) as { _id?: unknown } | null; const sync = await syncOpenClawInstallationsForPodSkillChange({ podId }); return res.status(201).json({ assetId: asset?._id, podId, name, scope: normalizedScope, sync }); @@ -340,7 +404,7 @@ router.post('/import', auth, async (req: AuthReq, res: Res) => { return sourceUnavailableReply(res, error as { missing: boolean; message: string }, 'import'); } console.error('Error importing skill:', error); - return res.status(e.code === 'POD_ACCESS_DENIED' ? 403 : 500).json({ error: e.message || 'Failed to import skill' }); + return res.status(skillWriteStatus(e)).json({ error: e.message || 'Failed to import skill' }); } }); diff --git a/backend/services/podAssetService.ts b/backend/services/podAssetService.ts index cea98d6502..3eaeb25257 100644 --- a/backend/services/podAssetService.ts +++ b/backend/services/podAssetService.ts @@ -1,5 +1,7 @@ // eslint-disable-next-line global-require const PodAsset = require('../models/PodAsset'); +// eslint-disable-next-line global-require +const mongoose = require('mongoose'); const STOP_WORDS = new Set([ 'about', 'after', 'again', 'against', 'almost', 'also', 'among', 'and', @@ -87,9 +89,12 @@ function toSkillKey(value: string): string { const normalized = String(value || '') .trim() .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return normalized.slice(0, 64); + .replace(/[^a-z0-9]+/g, '-'); + let start = 0; + let end = normalized.length; + while (normalized[start] === '-') start += 1; + while (end > start && normalized[end - 1] === '-') end -= 1; + return normalized.slice(start, Math.min(end, start + 64)); } function tokenize(text: string): string[] { @@ -339,6 +344,8 @@ class PodAssetService { static async upsertImportedSkillAsset({ podId, name, markdown, tags = [], metadata = {}, createdBy, }: ImportedSkillAssetInput): Promise { + if (!mongoose.Types.ObjectId.isValid(String(podId))) throw new Error('Invalid pod id'); + const podObjectId = new mongoose.Types.ObjectId(String(podId)); const meta = metadata as Record; const scope = (meta?.scope as string) || 'pod'; const skillKey = PodAssetService.buildScopedSkillKey({ @@ -362,10 +369,10 @@ class PodAssetService { }; const asset = await PodAsset.findOneAndUpdate( - { podId, type: 'skill', 'metadata.skillKey': skillKey }, + { podId: podObjectId, type: 'skill', 'metadata.skillKey': skillKey }, { $set: { - podId, + podId: podObjectId, type: 'skill', title, content: truncate(markdown || '', 8000), diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 0081dc97db..98c7100001 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -294,6 +294,7 @@ describe('claude adapter — ctx.environment', () => { test('mounts skills.claude entries into /.claude/skills/ as read-only copies', async () => { const { impl } = makeSpawnImpl(); + const onSkillsLinked = jest.fn(); const skillSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-skill-')); fs.writeFileSync(path.join(skillSrc, 'SKILL.md'), 'x', 'utf8'); @@ -301,6 +302,7 @@ describe('claude adapter — ctx.environment', () => { sessionId: null, cwd, environment: { skills: { claude: [skillSrc] } }, + onSkillsLinked, _spawnImpl: impl, }); @@ -310,6 +312,7 @@ describe('claude adapter — ctx.environment', () => { // guardrail was lost from cli 0.1.4 exactly this way). expect(fs.lstatSync(slot).isSymbolicLink()).toBe(false); expect(fs.readFileSync(path.join(slot, 'SKILL.md'), 'utf8')).toBe('x'); + expect(onSkillsLinked).toHaveBeenCalledWith({ mounted: [skillSrc], conflicted: [] }); fs.rmSync(skillSrc, { recursive: true, force: true }); }); @@ -1114,17 +1117,23 @@ describe('claude adapter — a public seat gets its declared skills as prompt te fs.mkdirSync(big); fs.writeFileSync(path.join(big, 'SKILL.md'), `---\nname: big-skill\n---\n\n${'x'.repeat(100 * 1024)}\n`, 'utf8'); const { impl, calls } = makeSpawnImpl(); + const onSkillsLinked = jest.fn(); await publicSpawn(impl, { environment: { sandbox: { mode: 'workspace', trust: 'public' }, skills: { claude: [big, skillSrc] }, }, + onSkillsLinked, }); const text = appended(calls[0].args); expect(text).not.toContain('## Skill: big-skill'); expect(text).toContain('## Skill: house-rules'); expect(Buffer.byteLength(text, 'utf8')).toBeLessThan(128 * 1024); + expect(onSkillsLinked).toHaveBeenCalledWith({ + mounted: [skillSrc], + conflicted: [{ path: big, reason: 'not-inlined' }], + }); }); test('the fresh-session retry carries the same skill text', async () => { diff --git a/cli/__tests__/adapters.codex.skills.test.mjs b/cli/__tests__/adapters.codex.skills.test.mjs index 8f661f97d7..af8bacfd53 100644 --- a/cli/__tests__/adapters.codex.skills.test.mjs +++ b/cli/__tests__/adapters.codex.skills.test.mjs @@ -88,7 +88,8 @@ describe('codex adapter — a public seat gets its declared skills as AGENTS.md' test('the skill body is in the public home\'s AGENTS.md before codex starts, without frontmatter', async () => { const { impl, calls } = makeSpawnImpl(); - await spawnSeat(impl, { sandbox: { trust: 'public' }, skills: { claude: [skillSrc] } }); + const onSkillsLinked = jest.fn(); + await spawnSeat(impl, { sandbox: { trust: 'public' }, skills: { claude: [skillSrc] } }, { onSkillsLinked }); expect(calls).toHaveLength(1); expect(calls[0].opts.env.CODEX_HOME).toBe(publicHome); @@ -97,6 +98,7 @@ describe('codex adapter — a public seat gets its declared skills as AGENTS.md' expect(seen).toContain('# Being a good Commonly member\n\nOrient before you post.'); expect(seen).not.toContain('description: How to behave'); expect(seen).not.toContain('private instructions'); + expect(onSkillsLinked).toHaveBeenCalledWith({ mounted: [skillSrc], conflicted: [] }); }); test('the file is private to the operator', async () => { @@ -130,11 +132,13 @@ describe('codex adapter — a public seat gets its declared skills as AGENTS.md' await rm(join(skillSrc, 'SKILL.md')); const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const { impl, calls } = makeSpawnImpl(); - await spawnSeat(impl, { sandbox: { trust: 'public' }, skills: { claude: [skillSrc] } }); + const onSkillsLinked = jest.fn(); + await spawnSeat(impl, { sandbox: { trust: 'public' }, skills: { claude: [skillSrc] } }, { onSkillsLinked }); expect(calls).toHaveLength(1); expect(await calls[0].seen).toBeNull(); expect(warn).toHaveBeenCalledWith(`[codex] public seat: no SKILL.md to inline at ${skillSrc}`); + expect(onSkillsLinked).toHaveBeenCalledWith({ mounted: [], conflicted: [{ path: skillSrc, reason: 'not-inlined' }] }); warn.mockRestore(); }); diff --git a/cli/__tests__/pod-focus-run-loop.test.mjs b/cli/__tests__/pod-focus-run-loop.test.mjs index 796d5482d9..e17d74d436 100644 --- a/cli/__tests__/pod-focus-run-loop.test.mjs +++ b/cli/__tests__/pod-focus-run-loop.test.mjs @@ -81,6 +81,107 @@ describe('performRun pod-focus turn seam', () => { ); }); + test('an added agent skill reaches the next spawn and disappears after removal', async () => { + let installed = true; + let eventId = 'skill-add-1'; + const get = jest.fn(async (route) => { + if (route === '/api/agents/runtime/events') return { events: [event({ _id: eventId })] }; + if (route === '/api/agents/runtime/memory') return { sections: {} }; + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/context')) return { focus: { podId: 'pod-focus', revision: 0, focus: null } }; + if (route.endsWith('/skills')) return { items: installed ? [{ name: 'review', content: '---\nname: review\n---\nCheck the patch.' }] : [] }; + return {}; + }); + const post = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn().mockResolvedValue({ text: 'done' }); + const run = () => performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_skills', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'focus-stub', setTimeoutImpl: noopTimeout, + }); + + const first = run(); + await drain(); + first.stop(); + expect(spawn.mock.calls[0][0]).toContain('## Skill: review\n\nCheck the patch.'); + expect(post).toHaveBeenCalledWith('/api/agents/runtime/pods/pod-focus/skill-load', { + mounted: ['review'], conflicted: [], + }); + + installed = false; + eventId = 'skill-remove-2'; + const second = run(); + await drain(); + second.stop(); + expect(spawn.mock.calls[1][0]).not.toContain('## Skill: review'); + expect(get).toHaveBeenCalledWith('/api/agents/runtime/pods/pod-focus/skills', {}, { + signal: expect.any(AbortSignal), + }); + }); + + test('a failed server skill read still spawns and reports unavailable', async () => { + const get = jest.fn(async (route) => { + if (route === '/api/agents/runtime/events') return { events: [event({ _id: 'skill-read-500' })] }; + if (route === '/api/agents/runtime/memory') return { sections: {} }; + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/context')) return { focus: { podId: 'pod-focus', revision: 0, focus: null } }; + if (route.endsWith('/skills')) throw Object.assign(new Error('skills unavailable'), { status: 500 }); + return {}; + }); + const post = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn().mockResolvedValue({ text: 'done' }); + const log = jest.fn(); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_skills', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'focus-stub', setTimeoutImpl: noopTimeout, log, + }); + await drain(); + stop(); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn.mock.calls[0][0]).not.toContain('## Skill:'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('server skills unavailable (HTTP 500)')); + expect(post).toHaveBeenCalledWith('/api/agents/runtime/pods/pod-focus/skill-load', { + mounted: [], conflicted: [], status: 'unavailable', + }); + expect(post).toHaveBeenCalledWith('/api/agents/runtime/events/skill-read-500/ack', { + result: { outcome: 'posted' }, + }); + }); + + test('a rejected spawn does not report server or adapter skills as loaded', async () => { + const get = jest.fn(async (route) => { + if (route === '/api/agents/runtime/events') return { events: [event({ _id: 'skill-failed-spawn' })] }; + if (route === '/api/agents/runtime/memory') return { sections: {} }; + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/context')) return { focus: { podId: 'pod-focus', revision: 0, focus: null } }; + if (route.endsWith('/skills')) return { items: [{ name: 'review', content: 'Check the patch.' }] }; + return {}; + }); + const post = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn(async (_prompt, ctx) => { + ctx.onSkillsLinked({ mounted: ['/tmp/local-review'], conflicted: [] }); + throw new Error('adapter failed before child start'); + }); + const errors = []; + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_skills', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'focus-stub', setTimeoutImpl: noopTimeout, + onError: (error) => errors.push(error), + }); + await drain(); + stop(); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(post.mock.calls.filter(([route]) => route.endsWith('/skill-load'))).toHaveLength(0); + expect(errors).toHaveLength(1); + }); + test('does not spawn or acknowledge when the turn-start focus read fails', async () => { const get = jest.fn(async (route) => { if (route === '/api/agents/runtime/events') return { events: [event({ _id: 'focus-fail-1' })] }; diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index bf42ceea2f..44be6f16a7 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -1633,6 +1633,7 @@ describe('performRun', () => { // answer that route explicitly so it doesn't consume the event queue. if (route.endsWith('/messages')) return { messages: [] }; if (route.endsWith('/context')) return { podId: 'pod-abc', revision: 0, focus: null }; + if (route.endsWith('/skills')) return { items: [] }; const id = eventIds[eventTurn]; eventTurn += 1; return { events: id ? [makeEvent({ _id: id })] : [] }; diff --git a/cli/__tests__/server-skills.test.mjs b/cli/__tests__/server-skills.test.mjs new file mode 100644 index 0000000000..f2f15dcfab --- /dev/null +++ b/cli/__tests__/server-skills.test.mjs @@ -0,0 +1,13 @@ +import { compileServerSkills } from '../src/lib/server-skills.js'; + +test('keeps whole skill bodies and reports a skill omitted by the prompt budget', () => { + const result = compileServerSkills([ + { name: 'large', content: 'x'.repeat(70 * 1024) }, + { name: 'review', content: '---\nname: review\n---\nCheck the patch.' }, + ]); + expect(result.mounted).toEqual(['review']); + expect(result.conflicted).toEqual(['large']); + expect(result.text).toContain('## Skill: review\n\nCheck the patch.'); + expect(result.text).not.toContain('name: review'); + expect(result.text).not.toContain('## Skill: large'); +}); diff --git a/cli/package-lock.json b/cli/package-lock.json index 0fede37d39..68df616fe4 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.134", + "version": "0.1.135", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.134", + "version": "0.1.135", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index eef2b7eb28..ee66d6d795 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.134", + "version": "0.1.135", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 7b7683fac0..1ed0160b53 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -12,7 +12,7 @@ */ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } from 'fs'; -import { join, dirname, resolve as pathResolve } from 'path'; +import { join, dirname, basename, resolve as pathResolve } from 'path'; import { homedir, tmpdir } from 'os'; import { fileURLToPath } from 'url'; import { createHash } from 'crypto'; @@ -39,7 +39,8 @@ import { import { readLongTerm, syncBack } from '../lib/memory-bridge.js'; import { pollRetryPolicy } from '../lib/poll-retry.js'; import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js'; -import { detectSkills, importSkills } from '../lib/skills-import.js'; +import { detectSkills, importSkills, parseSkillMeta } from '../lib/skills-import.js'; +import { compileServerSkills } from '../lib/server-skills.js'; import { environmentForServer, normalizeSandboxTrust, @@ -498,6 +499,14 @@ const BUNDLED_COMMONLY_SKILL_DIR = pathResolve( dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills', 'commonly', ); +const reportedSkillName = (source) => { + try { + return parseSkillMeta(readFileSync(join(source, 'SKILL.md'), 'utf8'), basename(source)).name; + } catch { + return basename(source); + } +}; + // Minimum-viable environment for fresh attach: wire @commonlyai/mcp so the // agent gets commonly_* kernel tools out of the box, AND mount the commonly // behavior skill so the agent knows the house rules (orient before posting, @@ -1581,6 +1590,25 @@ export const performRun = ({ throw error; } const promptWithFocus = `${focusFrame}\n\n${prompt}`; + let serverSkills; + let skillReadUnavailable = false; + try { + const response = await client.get(`/api/agents/runtime/pods/${eventPodId}/skills`, {}, { + signal: AbortSignal.timeout(5000), + }); + serverSkills = compileServerSkills(response?.items || []); + } catch (error) { + // Skill delivery is optional to the turn. A read failure must not stop + // every seat from spawning; report the missing observation explicitly. + if (error?.status !== 404) { + skillReadUnavailable = true; + log(`server skills unavailable (HTTP ${error?.status || 'no response'}); spawning without them`); + } + serverSkills = compileServerSkills(); + } + const promptWithSkills = serverSkills.text + ? `${focusFrame}\n\n${serverSkills.text}\n\n${prompt}` + : promptWithFocus; log(`[${event.type}] spawning ${adapter.name}`); // TASK-102 part B. The CHILD gets a scoped credential; the wrapper keeps @@ -1643,6 +1671,10 @@ export const performRun = ({ }; } let result; + let skillMount = skillReadUnavailable || serverSkills.mounted.length || serverSkills.conflicted.length + ? { mounted: serverSkills.mounted, conflicted: serverSkills.conflicted, ...(skillReadUnavailable ? { status: 'unavailable' } : {}) } + : null; + let spawnCompleted = false; let commitAttribution = null; try { // The local CLI is a wrapper for a Commonly seat, so its Git commits @@ -1660,13 +1692,20 @@ export const performRun = ({ effort: seatEnvironment?.effort, }); } - result = await adapter.spawn(frameDecisionForkRule(promptWithFocus), { + result = await adapter.spawn(frameDecisionForkRule(promptWithSkills), { sessionId, cwd: agentCwd, env: commitAttribution?.env || process.env, commitAttributionEnv: commitAttribution?.sandboxEnv || null, memoryLongTerm, environment: seatEnvironment, + onSkillsLinked: ({ mounted, conflicted }) => { + skillMount = { + mounted: [...new Set([...serverSkills.mounted, ...mounted.map(reportedSkillName)])], + conflicted: [...new Set([...serverSkills.conflicted, ...conflicted.map(({ path }) => reportedSkillName(path))])], + ...(skillReadUnavailable ? { status: 'unavailable' } : {}), + }; + }, // Runtime context the Claude/Codex adapters expose only to their // per-spawn MCP environment. Claude keeps ${COMMONLY_*} placeholders // literal on disk and lets its native MCP parser expand them, so the @@ -1678,10 +1717,18 @@ export const performRun = ({ agentName, metadata: { event }, }); + spawnCompleted = true; } finally { try { commitAttribution?.cleanup(); } finally { + if (spawnCompleted && skillMount) { + try { + await client.post(`/api/agents/runtime/pods/${eventPodId}/skill-load`, skillMount); + } catch (error) { + log(`skill load status could not be recorded: ${error.message}`); + } + } // Best-effort by construction: `close` logs and returns on a failed // revoke, so a turn that already produced an answer is not failed by // cleanup. The boot sweep collects anything left behind. diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 1184fbdb18..35b37ff06b 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -276,8 +276,13 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => { // total is capped well under it. const PUBLIC_SKILL_PROMPT_MAX_BYTES = 96 * 1024; const publicSkillPromptArgs = async (skillSources) => { - const text = await skillsAsText(skillSources, { maxBytes: PUBLIC_SKILL_PROMPT_MAX_BYTES, label: 'claude' }); - return text ? ['--append-system-prompt', text] : []; + const included = []; + const text = await skillsAsText(skillSources, { + maxBytes: PUBLIC_SKILL_PROMPT_MAX_BYTES, + label: 'claude', + onIncluded: (source) => included.push(source), + }); + return { args: text ? ['--append-system-prompt', text] : [], included }; }; const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => { @@ -804,16 +809,17 @@ export default { const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId, ...modelArgs]; let mountedSkills = []; + let skillMount = { mounted: [], conflicted: [] }; if (ctx.environment && ctx.cwd) { const skills = await mountSkills(ctx.environment, ctx.cwd); mountedSkills = skills.mounted; + skillMount = skills; if (skills.conflicted.length > 0) { for (const c of skills.conflicted) { // eslint-disable-next-line no-console console.warn(`[claude] skill not mounted (${c.reason}): ${c.path}`); } } - if (ctx.onSkillsLinked) ctx.onSkillsLinked(skills); } let mcpConfig = null; @@ -830,7 +836,19 @@ export default { if (publicNativeSandbox) { publicClaudeState = await preparePublicClaudeState(ctx); } - if (publicTrust) publicSkillArgs = await publicSkillPromptArgs(mountedSkills); + if (publicTrust) { + const prompt = await publicSkillPromptArgs(mountedSkills); + publicSkillArgs = prompt.args; + skillMount = { + mounted: prompt.included, + conflicted: [ + ...skillMount.conflicted, + ...mountedSkills.filter((source) => !prompt.included.includes(source)) + .map((path) => ({ path, reason: 'not-inlined' })), + ], + }; + } + if (ctx.environment && ctx.cwd) ctx.onSkillsLinked?.(skillMount); if (Array.isArray(ctx.environment?.mcp) && ctx.environment.mcp.length > 0) { mcpConfig = await createMcpConfig(ctx.environment.mcp, { runtimeToken: ctx.runtimeToken, diff --git a/cli/src/lib/adapters/codex.js b/cli/src/lib/adapters/codex.js index 3872375701..15a9eba946 100644 --- a/cli/src/lib/adapters/codex.js +++ b/cli/src/lib/adapters/codex.js @@ -343,18 +343,30 @@ const PUBLIC_CODEX_INSTRUCTIONS_MAX_BYTES = 32 * 1024; const writePublicCodexInstructions = async (publicHome, ctx) => { const declared = Array.isArray(ctx.environment?.skills?.claude) ? ctx.environment.skills.claude : []; const baseDir = ctx.cwd || process.cwd(); + const sources = declared.map((source) => resolveSkillSource(source, baseDir)); + const included = []; const text = await skillsAsText( - declared.map((source) => resolveSkillSource(source, baseDir)), - { maxBytes: PUBLIC_CODEX_INSTRUCTIONS_MAX_BYTES, label: 'codex', trailer: '\n' }, + sources, + { + maxBytes: PUBLIC_CODEX_INSTRUCTIONS_MAX_BYTES, + label: 'codex', + trailer: '\n', + onIncluded: (source) => included.push(source), + }, ); const target = join(publicHome, 'AGENTS.md'); if (!text) { await rm(target, { force: true }); + ctx.onSkillsLinked?.({ mounted: [], conflicted: sources.map((path) => ({ path, reason: 'not-inlined' })) }); return; } const staged = `${target}.${process.pid}.tmp`; await writeFile(staged, text, { encoding: 'utf8', mode: 0o600 }); await rename(staged, target); + ctx.onSkillsLinked?.({ + mounted: included, + conflicted: sources.filter((source) => !included.includes(source)).map((path) => ({ path, reason: 'not-inlined' })), + }); }; const publicPermissionProfileFlags = ( diff --git a/cli/src/lib/api.js b/cli/src/lib/api.js index fcacb3d42c..b98eb1ab23 100644 --- a/cli/src/lib/api.js +++ b/cli/src/lib/api.js @@ -65,10 +65,10 @@ export const createClient = ({ instance = null, token = undefined } = {}) => { baseUrl, }; - const get = (path, params = {}) => { + const get = (path, params = {}, options = {}) => { const url = new URL(`${baseUrl}${path}`); Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v)); - return fetch(url.toString(), { headers: headers(authToken) }).then((res) => handleResponse(res, session)); + return fetch(url.toString(), { headers: headers(authToken), ...options }).then((res) => handleResponse(res, session)); }; const post = (path, body = {}) => fetch(`${baseUrl}${path}`, { diff --git a/cli/src/lib/server-skills.js b/cli/src/lib/server-skills.js new file mode 100644 index 0000000000..561a0b1291 --- /dev/null +++ b/cli/src/lib/server-skills.js @@ -0,0 +1,32 @@ +// Agent-scoped catalog skills arrive from Commonly at each turn start. The +// wrapper gives their instructions to either adapter as prompt text, so a +// browser add/remove changes the next spawn without touching local files. +const MAX_BYTES = 64 * 1024; +const PREAMBLE = 'Installed skills for this agent and pod. Follow the applicable instructions below.'; +const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/; + +export const compileServerSkills = (items = []) => { + const sections = []; + const mounted = []; + const conflicted = []; + let bytes = Buffer.byteLength(PREAMBLE); + for (const item of items) { + const name = String(item?.name || '').replace(/[\r\n]/g, ' ').trim(); + const content = String(item?.content || '').replace(FRONTMATTER, '').trim(); + if (!name || !content) continue; + const section = `## Skill: ${name}\n\n${content}`; + const nextBytes = Buffer.byteLength(section) + 2; + if (bytes + nextBytes > MAX_BYTES) { + conflicted.push(name); + continue; + } + sections.push(section); + mounted.push(name); + bytes += nextBytes; + } + return { + text: sections.length ? `${PREAMBLE}\n\n${sections.join('\n\n')}` : null, + mounted, + conflicted, + }; +}; diff --git a/cli/src/lib/skill-prompt.js b/cli/src/lib/skill-prompt.js index 54879b5b72..4f197e97fa 100644 --- a/cli/src/lib/skill-prompt.js +++ b/cli/src/lib/skill-prompt.js @@ -35,7 +35,7 @@ const SECTION_SEPARATOR = '\n\n'; * would cross the cap, is skipped with a warning naming the runtime (`label`) * rather than failing the spawn. */ -export const readSkillSections = async (skillSources, { maxBytes, label, trailer = '' }) => { +export const readSkillSections = async (skillSources, { maxBytes, label, trailer = '', onIncluded }) => { const sections = []; let total = Buffer.byteLength(SKILLS_AS_TEXT_PREAMBLE, 'utf8') + Buffer.byteLength(trailer, 'utf8'); for (const source of skillSources || []) { @@ -60,6 +60,7 @@ export const readSkillSections = async (skillSources, { maxBytes, label, trailer } total += bytes; sections.push(section); + onIncluded?.(source); } return sections; }; diff --git a/cli/src/lib/skills-import.js b/cli/src/lib/skills-import.js index 187cdd9324..c22ecf72c5 100644 --- a/cli/src/lib/skills-import.js +++ b/cli/src/lib/skills-import.js @@ -101,6 +101,7 @@ export const importSkills = async (client, { skills, podId, agentName, instanceI podId, name: skill.name, content: skillStub(skill), + metadataOnly: true, description: skill.description || undefined, scope: 'agent', agentName, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d18413b066..6d33b9b01a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -28,6 +28,31 @@ "text": "Checking your access…" } }, + "agentSkills": { + "title": "Skills", + "pod": "Pod", + "addSkill": "Add skill", + "closeCatalog": "Close catalog", + "loading": "Loading skills…", + "empty": "No skills installed for this agent.", + "noReport": "No spawn report yet", + "loaded": "Loaded at last spawn", + "conflicted": "Could not load at last spawn", + "notLoaded": "Not loaded at last spawn", + "unavailable": "Skill status unavailable at last spawn", + "reported": "Last spawn report · {{date}}", + "find": "Find a skill", + "search": "Search catalog", + "add": "Add", + "addNamed": "Add {{name}}", + "remove": "Remove", + "removeNamed": "Remove {{name}}", + "noMatches": "No matching skills.", + "loadError": "Couldn’t load skills.", + "catalogError": "Couldn’t load the skill catalog.", + "addError": "Couldn’t add this skill.", + "removeError": "Couldn’t remove this skill." + }, "settings": { "username": { "save": "Save username", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 4be56997d3..442133cc0c 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -28,6 +28,31 @@ "text": "正在检查你的访问权限…" } }, + "agentSkills": { + "title": "技能", + "pod": "Pod", + "addSkill": "添加技能", + "closeCatalog": "关闭目录", + "loading": "正在加载技能…", + "empty": "此智能体尚未安装技能。", + "noReport": "暂无运行加载报告", + "loaded": "上次运行已加载", + "conflicted": "上次运行未能加载", + "notLoaded": "上次运行未加载", + "unavailable": "上次运行时技能状态不可用", + "reported": "上次运行报告 · {{date}}", + "find": "查找技能", + "search": "搜索目录", + "add": "添加", + "addNamed": "添加 {{name}}", + "remove": "移除", + "removeNamed": "移除 {{name}}", + "noMatches": "没有匹配的技能。", + "loadError": "无法加载技能。", + "catalogError": "无法加载技能目录。", + "addError": "无法添加此技能。", + "removeError": "无法移除此技能。" + }, "settings": { "username": { "save": "保存用户名", diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index d29b9101a0..8a60e13dd2 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -317,6 +317,9 @@ test('selects an installation the owner can manage without exposing a foreign po writablePodId: 'owned', installations: [{ podId: 'owned', podName: 'My pod', status: 'active' }], } }); if (url.includes('/pods/foreign/')) return Promise.reject({ response: { status: 403 } }); + if (url.startsWith('/api/skills/pods/owned/')) return Promise.resolve({ data: { + items: [], canEdit: true, skillLoad: null, + } }); if (url.includes('/pods/owned/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'webhook' }, } } }); @@ -325,6 +328,21 @@ test('selects an installation the owner can manage without exposing a foreign po axios.post.mockResolvedValue({ data: { token: 'cm_agent_once' } }); renderPage(); await screen.findByRole('button', { name: 'Regenerate key' }); + await waitFor(() => expect(axios.get).toHaveBeenCalledWith( + '/api/skills/pods/owned/imported', + { params: { scope: 'agent', agentName: 'maya-claude', instanceId: 'default' } }, + )); + expect(axios.get).not.toHaveBeenCalledWith( + '/api/skills/pods/foreign/imported', expect.anything(), + ); + expect(screen.getByText('Pod · My pod')).toBeInTheDocument(); + expect(screen.queryByText('Pod · Other pod')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Key' }).compareDocumentPosition( + screen.getByRole('heading', { name: 'Skills' }), + ) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Wake' }).compareDocumentPosition( + screen.getByRole('heading', { name: 'Skills' }), + ) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); fireEvent.click(screen.getByRole('button', { name: /replace the running agent/i })); await waitFor(() => expect(axios.post).toHaveBeenCalledWith( @@ -350,10 +368,18 @@ test('runtime editor writes environment controls through the shared settings hoo }); renderPage(); const model = await screen.findByLabelText('Model'); + await screen.findByRole('heading', { name: 'Wake' }); + await screen.findByRole('heading', { name: 'Policy' }); + await screen.findByRole('heading', { name: 'Skills' }); const sections = Array.from(document.querySelectorAll('.v2-agent-settings h2')).map((node) => node.textContent); + expect(sections).toEqual(expect.arrayContaining(['Identity', 'Policy', 'Skills'])); + expect(sections.indexOf('Key')).toBeLessThan(sections.indexOf('Identity')); + expect(sections.indexOf('Identity')).toBeLessThan(sections.indexOf('Wake')); expect(sections.indexOf('Key')).toBeLessThan(sections.indexOf('Runtime')); expect(sections.indexOf('Wake')).toBeLessThan(sections.indexOf('Runtime')); expect(sections.indexOf('Runtime')).toBeLessThan(sections.indexOf('Connector grants')); + expect(sections.indexOf('Connector grants')).toBeLessThan(sections.indexOf('Policy')); + expect(sections.indexOf('Policy')).toBeLessThan(sections.indexOf('Skills')); fireEvent.change(model, { target: { value: 'new-model' } }); fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( diff --git a/frontend/src/v2/__tests__/V2AgentSkills.test.tsx b/frontend/src/v2/__tests__/V2AgentSkills.test.tsx new file mode 100644 index 0000000000..96cf09ada9 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentSkills.test.tsx @@ -0,0 +1,60 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import axios from '../../utils/axiosConfig'; +import V2AgentSkills from '../components/V2AgentSkills'; + +jest.mock('../../utils/axiosConfig', () => ({ + __esModule: true, + default: { get: jest.fn(), post: jest.fn(), delete: jest.fn() }, +})); + +const installed = { + items: [{ name: 'review', description: 'Checks code' }, { name: 'draft', description: 'Writes drafts' }], + canEdit: true, + skillLoad: { at: '2026-10-23T16:00:00Z', mounted: ['review'], conflicted: ['draft'] }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/catalog') + ? { items: [{ name: 'plan', description: 'Plans work', sourceUrl: 'https://github.com/example/plan' }] } + : installed })); +}); + +test('shows the last reported spawn state and installer controls', async () => { + render(); + expect(screen.getByText('Pod · Planning')).toBeInTheDocument(); + expect(await screen.findByText('Loaded at last spawn')).toBeInTheDocument(); + expect(screen.getByText('Could not load at last spawn')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add skill' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add skill' })); + expect(await screen.findByRole('button', { name: 'Add plan' })).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Find a skill'), { target: { value: 'plan' } }); + await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/skills/catalog', { + params: { source: 'awesome', limit: 60, q: 'plan' }, + })); + expect(await screen.findByRole('button', { name: 'Add plan' })).toBeInTheDocument(); + axios.post.mockResolvedValue({ data: {} }); + fireEvent.click(screen.getByRole('button', { name: 'Add plan' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/skills/import', expect.objectContaining({ + podId: 'pod', agentName: 'scout', instanceId: 'default', scope: 'agent', name: 'plan', + }))); +}); + +test('shows installed skills read-only to other pod members', async () => { + axios.get.mockResolvedValue({ data: { ...installed, canEdit: false, skillLoad: null } }); + render(); + expect(await screen.findAllByText('No spawn report yet')).toHaveLength(2); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); +}); + +test('shows unavailable rather than not loaded when the last skill read failed', async () => { + axios.get.mockResolvedValue({ data: { + ...installed, + skillLoad: { at: '2026-10-23T16:00:00Z', mounted: [], conflicted: [], status: 'unavailable' }, + } }); + render(); + expect(await screen.findAllByText('Skill status unavailable at last spawn')).toHaveLength(2); + expect(screen.queryByText('Not loaded at last spawn')).not.toBeInTheDocument(); +}); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index c00cd55044..6d801a7619 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -10,6 +10,7 @@ import { useAgentSettings } from './useAgentSettings'; import V2AgentIdentitySection from './V2AgentIdentitySection'; import V2AgentWakePods from './V2AgentWakePods'; import V2AgentPolicy from './V2AgentPolicy'; +import V2AgentSkills from '../components/V2AgentSkills'; import './v2-agent-settings.css'; interface RuntimeKey { @@ -257,6 +258,9 @@ const V2AgentSettingsPage: React.FC = () => { /> +
+ pod.id === podId)?.name || podId} agentName={agentName} instanceId={instanceId} /> +
); }; diff --git a/frontend/src/v2/components/V2AgentSkills.tsx b/frontend/src/v2/components/V2AgentSkills.tsx new file mode 100644 index 0000000000..ff516c3a55 --- /dev/null +++ b/frontend/src/v2/components/V2AgentSkills.tsx @@ -0,0 +1,163 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import axios from '../../utils/axiosConfig'; +import './v2-agent-skills.css'; + +interface SkillItem { name: string; description?: string | null; sourceUrl?: string | null } +interface SkillLoad { at: string; mounted: string[]; conflicted: string[]; status?: 'available' | 'unavailable' } +interface SkillsResponse { items: SkillItem[]; canEdit: boolean; skillLoad: SkillLoad | null } +interface CatalogItem extends SkillItem { content?: string; license?: string | { name?: string } } + +interface Props { podId: string; podName: string; agentName: string; instanceId: string } + +const requestError = (error: unknown, fallback: string): string => ( + (error as { response?: { data?: { error?: string } } })?.response?.data?.error || fallback +); + +const loadLabel = (skill: SkillItem, load: SkillLoad | null, t: (key: string) => string): string => { + if (!load) return t('agentSkills.noReport'); + const name = skill.name.toLowerCase(); + if (load.mounted.some((entry) => entry.toLowerCase() === name)) return t('agentSkills.loaded'); + if (load.conflicted.some((entry) => entry.toLowerCase() === name)) return t('agentSkills.conflicted'); + if (load.status === 'unavailable') return t('agentSkills.unavailable'); + return t('agentSkills.notLoaded'); +}; + +const V2AgentSkills: React.FC = ({ podId, podName, agentName, instanceId }) => { + const { t } = useTranslation(); + const [skills, setSkills] = useState([]); + const [skillLoad, setSkillLoad] = useState(null); + const [canEdit, setCanEdit] = useState(false); + const [catalog, setCatalog] = useState([]); + const [catalogOpen, setCatalogOpen] = useState(false); + const [catalogLoading, setCatalogLoading] = useState(false); + const [query, setQuery] = useState(''); + const [busy, setBusy] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + const response = await axios.get(`/api/skills/pods/${encodeURIComponent(podId)}/imported`, { + params: { scope: 'agent', agentName, instanceId }, + }); + setSkills(response.data.items || []); + setSkillLoad(response.data.skillLoad || null); + setCanEdit(Boolean(response.data.canEdit)); + }, [podId, agentName, instanceId]); + + useEffect(() => { + let active = true; + setLoading(true); + setCanEdit(false); + setSkillLoad(null); + setError(null); + axios.get(`/api/skills/pods/${encodeURIComponent(podId)}/imported`, { + params: { scope: 'agent', agentName, instanceId }, + }).then((response) => { + if (!active) return; + setSkills(response.data.items || []); + setSkillLoad(response.data.skillLoad || null); + setCanEdit(Boolean(response.data.canEdit)); + }).catch((requestFailure) => { + if (active) setError(requestError(requestFailure, t('agentSkills.loadError'))); + }).finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [podId, agentName, instanceId, t]); + + useEffect(() => { + if (!catalogOpen) return undefined; + let active = true; + setCatalog([]); + setCatalogLoading(true); + const timer = setTimeout(() => { + axios.get<{ items: CatalogItem[] }>('/api/skills/catalog', { + params: { source: 'awesome', limit: 60, q: query.trim() }, + }).then((response) => { + if (active) setCatalog(response.data.items || []); + }).catch((requestFailure) => { + if (active) setError(requestError(requestFailure, t('agentSkills.catalogError'))); + }).finally(() => { if (active) setCatalogLoading(false); }); + }, 150); + return () => { active = false; clearTimeout(timer); }; + }, [catalogOpen, query, t]); + + const add = async (item: CatalogItem) => { + if (!canEdit || busy) return; + setBusy(item.name); + setError(null); + try { + await axios.post('/api/skills/import', { + podId, name: item.name, content: item.content || undefined, + sourceUrl: item.sourceUrl || undefined, + description: item.description || undefined, + license: typeof item.license === 'string' ? item.license : item.license?.name, + scope: 'agent', agentName, instanceId, + }); + await refresh(); + setCatalogOpen(false); + } catch (requestFailure) { + setError(requestError(requestFailure, t('agentSkills.addError'))); + } finally { + setBusy(null); + } + }; + + const remove = async (name: string) => { + if (!canEdit || busy) return; + setBusy(name); + setError(null); + try { + await axios.delete(`/api/skills/pods/${encodeURIComponent(podId)}/imported`, { + params: { name, scope: 'agent', agentName, instanceId }, + }); + await refresh(); + } catch (requestFailure) { + setError(requestError(requestFailure, t('agentSkills.removeError'))); + } finally { + setBusy(null); + } + }; + + const installed = new Set(skills.map((skill) => skill.name.toLowerCase())); + const available = catalog.filter((item) => !installed.has(item.name.toLowerCase())); + + return ( +
+
+

{t('agentSkills.title')}

+ {canEdit && } +
+

{t('agentSkills.pod')} · {podName}

+ {error &&

{error}

} + {loading ?

{t('agentSkills.loading')}

: ( + skills.length ?
+ {skills.map((skill) => ( +
+
+ {skill.name} + {skill.description && {skill.description}} + {loadLabel(skill, skillLoad, t)} +
+ {canEdit && } +
+ ))} +
:

{t('agentSkills.empty')}

+ )} + {skillLoad?.at &&

{t('agentSkills.reported', { date: new Date(skillLoad.at).toLocaleString() })}

} + {catalogOpen && canEdit &&
+ + setQuery(event.target.value)} placeholder={t('agentSkills.search')} /> +
+ {available.map((item) =>
+
{item.name}{item.description && {item.description}}
+ +
)} + {catalogLoading &&

{t('agentSkills.loading')}

} + {!catalogLoading && available.length === 0 &&

{t('agentSkills.noMatches')}

} +
+
} +
+ ); +}; + +export default V2AgentSkills; diff --git a/frontend/src/v2/components/v2-agent-skills.css b/frontend/src/v2/components/v2-agent-skills.css new file mode 100644 index 0000000000..cad1a11a9a --- /dev/null +++ b/frontend/src/v2/components/v2-agent-skills.css @@ -0,0 +1,21 @@ +.v2-agent-skills { color: var(--v2-text-primary); } +.v2-agent-skills__head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; } +.v2-agent-skills__head h2 { margin: 0; font-size: 18px; line-height: 24px; } +.v2-agent-skills__list { border: 1px solid var(--v2-border); border-radius: var(--v2-radius); overflow: hidden; } +.v2-agent-skills__row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 16px; } +.v2-agent-skills__row + .v2-agent-skills__row { border-top: 1px solid var(--v2-border-soft); } +.v2-agent-skills__copy { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.v2-agent-skills__copy strong { font-size: 14px; line-height: 20px; } +.v2-agent-skills__copy span { font-size: 13px; line-height: 18px; color: var(--v2-text-secondary); } +.v2-agent-skills__copy small, .v2-agent-skills__reported, .v2-agent-skills__pod { font: 500 11px/16px var(--v2-font-mono); color: var(--v2-text-muted); } +.v2-agent-skills__pod { margin: 0 0 12px; overflow-wrap: anywhere; } +.v2-agent-skills__empty { margin: 12px 0; color: var(--v2-text-muted); font-size: 13px; } +.v2-agent-skills__reported { margin: 8px 0 0; } +.v2-agent-skills__error { margin: 8px 0; font-size: 13px; color: var(--v2-text-primary); } +.v2-agent-skills__catalog { margin-top: 20px; } +.v2-agent-skills__catalog label { display: block; margin-bottom: 6px; color: var(--v2-text-secondary); font-size: 13px; font-weight: 500; } +.v2-agent-skills__catalog input { box-sizing: border-box; width: 100%; height: 40px; margin-bottom: 12px; padding: 0 12px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); } +.v2-root .v2-agent-skills button { flex: 0 0 auto; padding: 7px 12px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: var(--v2-surface); color: var(--v2-text-primary); font: 600 13px/18px var(--v2-font); cursor: pointer; } +.v2-root .v2-agent-skills button:hover:not(:disabled) { background: var(--v2-bg-subtle); } +.v2-root .v2-agent-skills button:disabled { opacity: .5; cursor: default; } +@media (max-width: 760px) { .v2-agent-skills__row { align-items: flex-start; } }