feat(skills): uninstall for user-scope SKILL.md skills (#781) (#833)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
oxoxDev
2026-04-24 17:23:40 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent b81d04df54
commit b329e45cdb
8 changed files with 1078 additions and 2 deletions
@@ -0,0 +1,144 @@
/**
* UninstallSkillConfirmDialog
* ---------------------------
*
* Small centered confirm modal for destructive uninstall of a user-scope
* SKILL.md skill. Wraps `skillsApi.uninstallSkill` which calls
* `openhuman.skills_uninstall` on the Rust side — that RPC only accepts
* user-scope installs (`~/.openhuman/skills/<name>/`) and refuses project
* and legacy scopes. The card that opens this dialog is responsible for
* not surfacing the Uninstall action for non-user-scope entries.
*
* UI contract:
* - Shows skill name, resolved on-disk path (when known), and a plain
* warning line.
* - "Cancel" dismisses. "Uninstall" fires the RPC.
* - While the RPC is in flight, both buttons disable and the modal is
* non-dismissable (Esc / backdrop ignored) so the caller sees the
* outcome.
* - On success, the parent's `onUninstalled(result)` callback runs and
* the dialog closes. On failure, the raw backend error is surfaced
* inline; the dialog stays open so the user can retry or cancel.
*
* Design mirrors `InstallSkillDialog` — see
* `.claude/rules/15-settings-modal-system.md`.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import debug from 'debug';
import {
skillsApi,
type SkillSummary,
type UninstallSkillResult,
} from '../../services/api/skillsApi';
const log = debug('skills:uninstall-dialog');
interface Props {
skill: SkillSummary;
onClose: () => void;
/**
* Fires when the backend reports the uninstall succeeded. Parent is
* responsible for refetching the skills list and closing any detail
* panels that were showing this skill.
*/
onUninstalled: (result: UninstallSkillResult) => void;
}
export default function UninstallSkillConfirmDialog({ skill, onClose, onUninstalled }: Props) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const cancelBtnRef = useRef<HTMLButtonElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
previousFocusRef.current = document.activeElement as HTMLElement | null;
cancelBtnRef.current?.focus();
return () => {
previousFocusRef.current?.focus();
};
}, []);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !submitting) {
e.preventDefault();
onClose();
}
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose, submitting]);
const handleConfirm = useCallback(async () => {
log('confirm: id=%s name=%s', skill.id, skill.name);
setSubmitting(true);
setError(null);
try {
// `skill.id` is the on-disk slug (directory under ~/.openhuman/skills/).
// `skill.name` is the frontmatter display name and may diverge from the
// slug — the backend resolves by slug, so pass `id`.
const result = await skillsApi.uninstallSkill(skill.id);
log('confirm: done removedPath=%s', result.removedPath);
onUninstalled(result);
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log('confirm: error=%s', msg);
setError(`Couldn't uninstall skill: ${msg}`);
setSubmitting(false);
}
}, [skill.id, skill.name, onUninstalled, onClose]);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="uninstall-skill-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onMouseDown={e => {
if (e.target === e.currentTarget && !submitting) onClose();
}}>
<div className="w-[420px] max-w-[90vw] rounded-2xl bg-white p-5 shadow-2xl">
<h2 id="uninstall-skill-title" className="text-base font-semibold text-stone-900">
Uninstall {skill.name}?
</h2>
<p className="mt-2 text-sm text-stone-600">
This permanently deletes the skill directory and all its bundled resources. The agent
will stop seeing it at the next turn.
</p>
{skill.location && (
<p className="mt-3 break-all rounded-lg bg-stone-50 px-3 py-2 font-mono text-[11px] text-stone-600">
{skill.location.replace(/\/SKILL\.md$/i, '')}
</p>
)}
{error && (
<div className="mt-3 rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700">
<div className="font-medium">Could not uninstall</div>
<div className="mt-1 break-words font-mono text-[11px] text-coral-700/90">{error}</div>
</div>
)}
<div className="mt-5 flex items-center justify-end gap-2">
<button
ref={cancelBtnRef}
type="button"
disabled={submitting}
onClick={onClose}
className="rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50">
Cancel
</button>
<button
type="button"
disabled={submitting}
onClick={handleConfirm}
data-testid="uninstall-skill-confirm"
className="rounded-lg border border-coral-300 bg-coral-50 px-3 py-1.5 text-xs font-medium text-coral-700 hover:bg-coral-100 disabled:cursor-not-allowed disabled:opacity-50">
{submitting ? 'Uninstalling…' : 'Uninstall'}
</button>
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,217 @@
/**
* UninstallSkillConfirmDialog — vitest coverage
*
* Verifies:
* - Renders skill name + on-disk path + destructive confirm copy.
* - Cancel button fires onClose, does NOT hit the RPC.
* - Confirm fires `skillsApi.uninstallSkill(name)` and forwards the result
* to `onUninstalled`, then closes.
* - RPC error is surfaced inline and the dialog stays open (no onClose).
* - While in-flight, both buttons disable and Esc no-ops (handled by
* disabled flag on the cancel button; dialog-level dismissal blocked).
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import UninstallSkillConfirmDialog from '../UninstallSkillConfirmDialog';
import type { SkillSummary } from '../../../services/api/skillsApi';
vi.mock('../../../services/api/skillsApi', () => ({
skillsApi: {
uninstallSkill: vi.fn(),
},
}));
const fixture: SkillSummary = {
id: 'weather-helper',
name: 'weather-helper',
description: 'Weather forecasts',
version: '',
author: null,
tags: [],
tools: [],
prompts: [],
location: '/Users/me/.openhuman/skills/weather-helper/SKILL.md',
resources: [],
scope: 'user',
legacy: false,
warnings: [],
};
describe('UninstallSkillConfirmDialog', () => {
beforeEach(async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.uninstallSkill).mockReset();
});
it('renders skill name, path (stripped of /SKILL.md), and confirm copy', () => {
render(
<UninstallSkillConfirmDialog
skill={fixture}
onClose={vi.fn()}
onUninstalled={vi.fn()}
/>
);
expect(screen.getByText(/Uninstall weather-helper\?/)).toBeInTheDocument();
expect(screen.getByText(/permanently deletes/i)).toBeInTheDocument();
expect(screen.getByText('/Users/me/.openhuman/skills/weather-helper')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Cancel/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Uninstall$/ })).toBeInTheDocument();
});
it('Confirm uses skill.id (slug), not skill.name (display), when they diverge', async () => {
// Regression test for #781: `Skill.name` comes from SKILL.md frontmatter
// and can differ from the on-disk directory. The uninstall RPC resolves
// by slug — the UI must pass `skill.id` (the slug).
const onClose = vi.fn();
const onUninstalled = vi.fn();
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.uninstallSkill).mockResolvedValueOnce({
name: 'weather-helper',
removedPath: '/Users/me/.openhuman/skills/weather-helper',
scope: 'user',
});
const divergent: SkillSummary = {
...fixture,
id: 'weather-helper',
name: 'Weather Helper (Pro)',
};
render(
<UninstallSkillConfirmDialog
skill={divergent}
onClose={onClose}
onUninstalled={onUninstalled}
/>
);
fireEvent.click(screen.getByTestId('uninstall-skill-confirm'));
await waitFor(() => {
expect(vi.mocked(skillsApi.uninstallSkill)).toHaveBeenCalledWith('weather-helper');
});
expect(vi.mocked(skillsApi.uninstallSkill)).not.toHaveBeenCalledWith('Weather Helper (Pro)');
});
it('Cancel fires onClose without calling the RPC', async () => {
const onClose = vi.fn();
const { skillsApi } = await import('../../../services/api/skillsApi');
render(
<UninstallSkillConfirmDialog
skill={fixture}
onClose={onClose}
onUninstalled={vi.fn()}
/>
);
fireEvent.click(screen.getByRole('button', { name: /Cancel/ }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(vi.mocked(skillsApi.uninstallSkill)).not.toHaveBeenCalled();
});
it('Confirm calls skillsApi.uninstallSkill and forwards result to onUninstalled', async () => {
const onClose = vi.fn();
const onUninstalled = vi.fn();
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.uninstallSkill).mockResolvedValueOnce({
name: 'weather-helper',
removedPath: '/Users/me/.openhuman/skills/weather-helper',
scope: 'user',
});
render(
<UninstallSkillConfirmDialog
skill={fixture}
onClose={onClose}
onUninstalled={onUninstalled}
/>
);
fireEvent.click(screen.getByTestId('uninstall-skill-confirm'));
await waitFor(() => {
expect(vi.mocked(skillsApi.uninstallSkill)).toHaveBeenCalledWith('weather-helper');
});
// Assert the caller passed the slug (`id`) — not the frontmatter
// display name. Regression guard for the #781 fix that swapped
// `skill.name` → `skill.id` in the confirm handler.
expect(vi.mocked(skillsApi.uninstallSkill)).toHaveBeenCalledWith(fixture.id);
await waitFor(() => {
expect(onUninstalled).toHaveBeenCalledWith({
name: 'weather-helper',
removedPath: '/Users/me/.openhuman/skills/weather-helper',
scope: 'user',
});
});
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
it('surfaces RPC errors inline and keeps the dialog open', async () => {
const onClose = vi.fn();
const onUninstalled = vi.fn();
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.uninstallSkill).mockRejectedValueOnce(
new Error("skill 'weather-helper' is not installed")
);
render(
<UninstallSkillConfirmDialog
skill={fixture}
onClose={onClose}
onUninstalled={onUninstalled}
/>
);
fireEvent.click(screen.getByTestId('uninstall-skill-confirm'));
await waitFor(() => {
expect(screen.getByText(/Could not uninstall/)).toBeInTheDocument();
});
expect(screen.getByText(/is not installed/)).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(onUninstalled).not.toHaveBeenCalled();
// Confirm button should be re-enabled so the user can retry.
const confirm = screen.getByTestId('uninstall-skill-confirm') as HTMLButtonElement;
expect(confirm.disabled).toBe(false);
});
it('disables buttons while the RPC is in flight', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
type UninstallResolve = (v: {
name: string;
removedPath: string;
scope: SkillSummary['scope'];
}) => void;
const deferred: { resolve?: UninstallResolve } = {};
vi.mocked(skillsApi.uninstallSkill).mockReturnValueOnce(
new Promise<{
name: string;
removedPath: string;
scope: SkillSummary['scope'];
}>(resolve => {
deferred.resolve = resolve;
})
);
render(
<UninstallSkillConfirmDialog
skill={fixture}
onClose={vi.fn()}
onUninstalled={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('uninstall-skill-confirm'));
await waitFor(() => {
const cancel = screen.getByRole('button', { name: /Cancel/ }) as HTMLButtonElement;
const confirm = screen.getByTestId('uninstall-skill-confirm') as HTMLButtonElement;
expect(cancel.disabled).toBe(true);
expect(confirm.disabled).toBe(true);
expect(confirm.textContent).toMatch(/Uninstalling/);
});
deferred.resolve?.({
name: 'weather-helper',
removedPath: '/Users/me/.openhuman/skills/weather-helper',
scope: 'user',
});
});
});
+71
View File
@@ -9,6 +9,7 @@ import {
KNOWN_COMPOSIO_TOOLKITS,
} from '../components/composio/toolkitMeta';
import ConnectionBadge, { isMessagingId } from '../components/ConnectionBadge';
import { ToastContainer } from '../components/intelligence/Toast';
import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal';
import CreateSkillModal from '../components/skills/CreateSkillModal';
import InstallSkillDialog from '../components/skills/InstallSkillDialog';
@@ -24,6 +25,7 @@ import {
SkillCategoryIcon,
} from '../components/skills/skillIcons';
import SkillSearchBar from '../components/skills/SkillSearchBar';
import UninstallSkillConfirmDialog from '../components/skills/UninstallSkillConfirmDialog';
import VoiceSetupModal from '../components/skills/VoiceSetupModal';
import { useAutocompleteSkillStatus } from '../features/autocomplete/useAutocompleteSkillStatus';
import { useScreenIntelligenceSkillStatus } from '../features/screen-intelligence/useScreenIntelligenceSkillStatus';
@@ -35,6 +37,7 @@ import { type ComposioConnection, deriveComposioState } from '../lib/composio/ty
import { skillsApi, type SkillSummary } from '../services/api/skillsApi';
import { useAppSelector } from '../store/hooks';
import type { ChannelConnectionStatus, ChannelDefinition, ChannelType } from '../types/channels';
import type { ToastNotification } from '../types/intelligence';
import { IS_DEV } from '../utils/config';
import { subconsciousEscalationsDismiss } from '../utils/tauriCommands';
@@ -194,6 +197,15 @@ export default function Skills() {
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [uninstallCandidate, setUninstallCandidate] = useState<SkillSummary | null>(null);
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
const newToast: ToastNotification = { ...toast, id: `toast-${Date.now()}-${Math.random()}` };
setToasts(prev => [...prev, newToast]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const pendingEscalationId =
location.state &&
typeof location.state === 'object' &&
@@ -631,6 +643,7 @@ export default function Skills() {
: skill.scope === 'project'
? 'text-amber-600'
: 'text-stone-600';
const canUninstall = skill.scope === 'user' && !skill.legacy;
return (
<UnifiedSkillCard
key={item.id}
@@ -647,6 +660,36 @@ export default function Skills() {
});
setSelectedSkill(skill);
}}
secondaryActions={
canUninstall
? [
{
label: 'Uninstall',
testId: `uninstall-skill-${skill.id}`,
icon: (
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14z"
/>
</svg>
),
onClick: () => {
console.debug('[skills][discovered] open uninstall', {
skillId: skill.id,
});
setUninstallCandidate(skill);
},
},
]
: undefined
}
/>
);
}
@@ -784,6 +827,34 @@ export default function Skills() {
}}
/>
)}
{uninstallCandidate && (
<UninstallSkillConfirmDialog
skill={uninstallCandidate}
onClose={() => setUninstallCandidate(null)}
onUninstalled={result => {
console.debug('[skills][uninstall] complete', {
name: result.name,
removedPath: result.removedPath,
});
addToast({
type: 'success',
title: 'Skill uninstalled',
message: `"${result.name}" was removed successfully.`,
});
// If the detail drawer was showing the skill we just removed,
// close it — the resource tree is now stale and any `read_resource`
// RPC would fail with a clean "not installed" error.
setSelectedSkill(prev => (prev && prev.id === result.name ? null : prev));
// Drop it from local state so the card disappears without a
// round-trip; refresh to pick up any side effects (e.g. a
// previously-shadowed project-scope skill now surfaces).
setDiscoveredSkills(prev => prev.filter(s => s.id !== result.name));
void refreshDiscoveredSkills();
}}
/>
)}
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
);
}
+43
View File
@@ -129,6 +129,25 @@ interface RawInstallSkillFromUrlResult {
new_skills: string[];
}
/**
* Result of `openhuman.skills_uninstall`.
*
* Mirrors the Rust-side `UninstallSkillOutcome`. `removedPath` is the
* canonicalised on-disk path that was deleted — surface it in success toasts
* so the user can confirm exactly what was removed.
*/
export interface UninstallSkillResult {
name: string;
removedPath: string;
scope: SkillScope;
}
interface RawUninstallSkillResult {
name: string;
removed_path: string;
scope: SkillScope;
}
interface Envelope<T> {
data?: T;
}
@@ -250,4 +269,28 @@ export const skillsApi = {
);
return normalized;
},
/**
* Remove an installed user-scope SKILL.md skill via `openhuman.skills_uninstall`.
*
* Only user-scope installs (`~/.openhuman/skills/<name>/`) are supported.
* Project-scope and legacy skills are read-only — trying to uninstall one
* returns a backend error surfaced as a rejected promise. The Rust side
* canonicalises paths and refuses names with separators / traversal
* sequences / anything outside the skills root.
*/
uninstallSkill: async (name: string): Promise<UninstallSkillResult> => {
log('uninstallSkill: request name=%s', name);
const response = await callCoreRpc<Envelope<RawUninstallSkillResult> | RawUninstallSkillResult>(
{ method: 'openhuman.skills_uninstall', params: { name } }
);
const raw = unwrapEnvelope(response);
const normalized: UninstallSkillResult = {
name: raw.name,
removedPath: raw.removed_path,
scope: raw.scope,
};
log('uninstallSkill: response name=%s removedPath=%s', normalized.name, normalized.removedPath);
return normalized;
},
};
+1
View File
@@ -508,6 +508,7 @@ mod tests {
fn skill(name: &str, description: &str) -> Skill {
Skill {
name: name.to_string(),
dir_name: name.to_string(),
description: description.to_string(),
version: "0.1.0".into(),
author: None,
+407
View File
@@ -151,6 +151,12 @@ fn extract_tags(fm: &SkillFrontmatter, warnings: &mut Vec<String>) -> Vec<String
pub struct Skill {
/// Display name (from frontmatter, falls back to directory name).
pub name: String,
/// On-disk slug — the directory name under `~/.openhuman/skills/` (user
/// scope) or the workspace skills directory (project scope). This is the
/// identifier the uninstall RPC resolves against; it may differ from
/// [`Skill::name`] when frontmatter declares a mismatched display name.
#[serde(default)]
pub dir_name: String,
/// Short description used in the catalog summary.
pub description: String,
/// Version string, if declared.
@@ -517,6 +523,7 @@ fn load_from_skill_md(skill_md: &Path, dir: &Path, dir_name: &str, scope: SkillS
Skill {
name,
dir_name: dir_name.to_string(),
description,
version,
author,
@@ -579,6 +586,7 @@ fn load_from_legacy_manifest(
Skill {
name,
dir_name: dir_name.to_string(),
description,
version: manifest.version,
author: manifest.author,
@@ -1741,6 +1749,203 @@ fn is_private_v6(ip: &Ipv6Addr) -> bool {
false
}
/// Input for [`uninstall_skill`]. Mirrors the `skills.uninstall` JSON-RPC
/// payload.
#[derive(Debug, Clone, Deserialize)]
pub struct UninstallSkillParams {
/// On-disk slug of the installed skill — the directory name under
/// `~/.openhuman/skills/<slug>/`. This is `SkillSummary.id` on the wire,
/// which may diverge from the frontmatter display name exposed as
/// `Skill.name` when a skill's `SKILL.md` declares a name that does not
/// match its directory. Callers must send the slug (`SkillSummary.id`),
/// not the display name, or the RPC will return "not installed".
///
/// Retained as `name` for wire-format back-compat with pre-existing
/// clients; the field's semantics are slug-only.
pub name: String,
}
/// Outcome of a successful uninstall.
#[derive(Debug, Clone, Serialize)]
pub struct UninstallSkillOutcome {
/// The normalised slug that was removed.
pub name: String,
/// Absolute on-disk path that was deleted (post-canonicalisation). Useful
/// for user-visible confirmations and for logs.
pub removed_path: String,
/// Scope the uninstall applied to. Always `User` today.
pub scope: SkillScope,
}
/// Remove an installed user-scope SKILL.md skill from `~/.openhuman/skills/`.
///
/// Only **user-scope** uninstalls are supported:
///
/// * Project-scope skills live inside the workspace — the user manages them
/// via git / editor / filesystem directly. Exposing a destructive delete
/// over RPC invites foot-guns, since a rogue skill's tool could uninstall
/// other project skills.
/// * Legacy `<ws>/skills/` skills are treated as read-only. They predate the
/// install flow and we don't want to blow away content the user may have
/// hand-authored.
///
/// Resolution is defensive:
/// 1. `skills_root = <home>/.openhuman/skills`, canonicalised.
/// 2. `candidate = skills_root.join(&name)`, canonicalised.
/// 3. Assert `candidate.starts_with(skills_root)` after canonicalisation —
/// blocks `..` traversal and symlink escapes.
/// 4. Refuse anything that is not a plain directory (no symlinks).
/// 5. Require `SKILL.md` to be present — we only remove things that look
/// like skills we installed, not arbitrary dirs the user dropped in.
/// 6. `fs::remove_dir_all`.
///
/// `home_dir_override` is threaded through for tests. Production callers
/// pass `None`, which falls back to [`dirs::home_dir`].
pub fn uninstall_skill(
params: UninstallSkillParams,
home_dir_override: Option<&Path>,
) -> Result<UninstallSkillOutcome, String> {
let trimmed = params.name.trim().to_string();
if trimmed.is_empty() {
return Err("skill name is required".to_string());
}
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
log::warn!(
"[skills] uninstall_skill: rejected name with path separators name={:?}",
trimmed
);
return Err(format!(
"skill name '{trimmed}' must not contain path separators"
));
}
if trimmed.len() > MAX_NAME_LEN {
return Err(format!(
"skill name is {} chars (max {MAX_NAME_LEN})",
trimmed.len()
));
}
let home = match home_dir_override
.map(|p| p.to_path_buf())
.or_else(dirs::home_dir)
{
Some(h) => h,
None => return Err("could not resolve user home directory".to_string()),
};
let skills_root = home.join(".openhuman").join("skills");
if !skills_root.exists() {
return Err(format!(
"no user skills directory at {}",
skills_root.display()
));
}
// Refuse a symlinked skills root before canonicalisation would silently
// follow it. `canonicalize` dereferences every component, so by the time
// `starts_with` runs below a symlinked `~/.openhuman/skills` would have
// already resolved elsewhere and the guard is a no-op.
let root_meta = std::fs::symlink_metadata(&skills_root)
.map_err(|e| format!("stat {} failed: {e}", skills_root.display()))?;
if root_meta.file_type().is_symlink() {
log::warn!(
"[skills] uninstall_skill: refused symlinked skills root path={}",
skills_root.display()
);
return Err(format!(
"skills root {} is a symlink — refusing to resolve",
skills_root.display()
));
}
let canonical_root = std::fs::canonicalize(&skills_root)
.map_err(|e| format!("canonicalize {} failed: {e}", skills_root.display()))?;
let candidate = skills_root.join(&trimmed);
// Reject `~/.openhuman/skills/<trimmed>` aliases that are themselves
// symlinks (e.g. `alias -> other-skill`). Same reasoning as the root
// check: canonicalise would follow the alias and `starts_with` would
// still pass, leading us to delete a different directory than the one
// the UI named.
match std::fs::symlink_metadata(&candidate) {
Ok(m) if m.file_type().is_symlink() => {
log::warn!(
"[skills] uninstall_skill: refused symlinked alias name={trimmed} path={}",
candidate.display()
);
return Err(format!(
"skill '{trimmed}' is a symlinked alias — refusing to resolve"
));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(format!("skill '{trimmed}' is not installed"));
}
Err(e) => {
return Err(format!("stat {} failed: {e}", candidate.display()));
}
}
// After the raw-path symlink guards above, canonicalise to resolve any
// `.` / `..` components introduced by a sneaky input that slipped past
// the earlier separator check, and map a still-possible NotFound race
// to the friendly message.
let canonical_candidate = std::fs::canonicalize(&candidate).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
format!("skill '{trimmed}' is not installed")
} else {
format!("canonicalize {} failed: {e}", candidate.display())
}
})?;
if !canonical_candidate.starts_with(&canonical_root) {
log::warn!(
"[skills] uninstall_skill: path escape rejected candidate={} root={}",
canonical_candidate.display(),
canonical_root.display()
);
return Err(format!(
"refused to remove {} — path escapes skills root",
canonical_candidate.display()
));
}
let meta = std::fs::symlink_metadata(&canonical_candidate)
.map_err(|e| format!("stat {} failed: {e}", canonical_candidate.display()))?;
if meta.file_type().is_symlink() || !meta.is_dir() {
return Err(format!(
"{} is not a directory — refusing to remove",
canonical_candidate.display()
));
}
// Best-effort guard: reject directories that don't look like SKILL.md
// skills before we commit to removing them. The subsequent
// `remove_dir_all` is the authoritative operation; a TOCTOU race here
// (file disappears between the check and the remove) is benign — the
// remove would fail with a clear error anyway.
let skill_md = canonical_candidate.join(SKILL_MD);
if !skill_md.exists() {
return Err(format!(
"{} does not look like a SKILL.md skill (missing {SKILL_MD})",
canonical_candidate.display()
));
}
log::info!(
"[skills] uninstall_skill: removing name={trimmed} path={}",
canonical_candidate.display()
);
std::fs::remove_dir_all(&canonical_candidate)
.map_err(|e| format!("remove {} failed: {e}", canonical_candidate.display()))?;
Ok(UninstallSkillOutcome {
name: trimmed,
removed_path: canonical_candidate.display().to_string(),
scope: SkillScope::User,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2710,4 +2915,206 @@ mod tests {
"expected warning, got {warnings:?}"
);
}
/// Happy path: install a SKILL.md under a synthetic user home, verify
/// discovery sees it, uninstall, verify discovery no longer sees it and
/// the on-disk dir is gone.
#[test]
fn uninstall_skill_removes_user_scope_dir() {
let home = tempfile::tempdir().unwrap();
let skill_dir = home
.path()
.join(".openhuman")
.join("skills")
.join("weather-helper");
write(
&skill_dir.join("SKILL.md"),
"---\nname: weather-helper\ndescription: forecasts\n---\n\nbody\n",
);
let before = discover_skills(Some(home.path()), None, false);
assert_eq!(before.len(), 1, "setup: skill should be discoverable");
let outcome = uninstall_skill(
UninstallSkillParams {
name: "weather-helper".into(),
},
Some(home.path()),
)
.unwrap();
assert_eq!(outcome.name, "weather-helper");
assert_eq!(outcome.scope, SkillScope::User);
assert!(!skill_dir.exists(), "uninstall should remove the dir");
let after = discover_skills(Some(home.path()), None, false);
assert!(after.is_empty(), "discovery should no longer see it");
}
/// Names containing path separators or traversal sequences are rejected
/// before any filesystem access.
#[test]
fn uninstall_skill_rejects_path_traversal_names() {
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home.path().join(".openhuman").join("skills")).unwrap();
for bad in ["../etc", "foo/bar", "foo\\bar", "..", "foo/../bar"] {
let err = uninstall_skill(UninstallSkillParams { name: bad.into() }, Some(home.path()))
.unwrap_err();
assert!(
err.contains("path separators") || err.contains("is not installed"),
"name {bad:?} should be rejected before fs access, got: {err}"
);
}
}
/// Empty and whitespace-only names return a clear required-field error.
#[test]
fn uninstall_skill_rejects_empty_name() {
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home.path().join(".openhuman").join("skills")).unwrap();
for bad in ["", " ", "\t"] {
let err = uninstall_skill(UninstallSkillParams { name: bad.into() }, Some(home.path()))
.unwrap_err();
assert!(err.contains("name is required"), "{bad:?} => {err}");
}
}
/// Uninstalling a skill that is not installed surfaces a recognizable
/// error rather than a generic I/O failure.
#[test]
fn uninstall_skill_missing_skill_errors_cleanly() {
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home.path().join(".openhuman").join("skills")).unwrap();
let err = uninstall_skill(
UninstallSkillParams {
name: "ghost".into(),
},
Some(home.path()),
)
.unwrap_err();
assert!(err.contains("not installed"), "got: {err}");
}
/// A directory that does not contain a `SKILL.md` is refused — we only
/// remove things that look like skills we installed, not arbitrary
/// directories the user dropped in.
#[test]
fn uninstall_skill_refuses_dir_without_skill_md() {
let home = tempfile::tempdir().unwrap();
let bogus = home.path().join(".openhuman").join("skills").join("bogus");
std::fs::create_dir_all(&bogus).unwrap();
std::fs::write(bogus.join("random.txt"), "not a skill").unwrap();
let err = uninstall_skill(
UninstallSkillParams {
name: "bogus".into(),
},
Some(home.path()),
)
.unwrap_err();
assert!(
err.contains("does not look like a SKILL.md skill"),
"got: {err}"
);
assert!(bogus.exists(), "non-skill dir should not be deleted");
}
/// A symlink inside the skills root pointing outside the root must be
/// rejected by the raw-path symlink preflight before `canonicalize`
/// would follow the link. The earlier `starts_with` / `is_dir` guards
/// remain as defence-in-depth for anything that slips past the
/// preflight on future refactors.
#[cfg(unix)]
#[test]
fn uninstall_skill_rejects_symlink_escape() {
let home = tempfile::tempdir().unwrap();
let skills_root = home.path().join(".openhuman").join("skills");
std::fs::create_dir_all(&skills_root).unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("real");
write(
&target.join("SKILL.md"),
"---\nname: real\ndescription: out of tree\n---\n",
);
std::os::unix::fs::symlink(&target, skills_root.join("real")).unwrap();
let err = uninstall_skill(
UninstallSkillParams {
name: "real".into(),
},
Some(home.path()),
)
.unwrap_err();
assert!(
err.contains("symlinked alias")
|| err.contains("path escapes skills root")
|| err.contains("is not a directory"),
"symlink out of tree must be rejected, got: {err}"
);
assert!(target.exists(), "symlink target must not be deleted");
}
/// An in-tree symlink alias (`skills/alias -> skills/real`) must be
/// rejected even though it does not escape the skills root — otherwise
/// the uninstall of `alias` would nuke the real skill directory behind
/// it, violating the invariant that the named slug is deleted.
#[cfg(unix)]
#[test]
fn uninstall_skill_rejects_symlinked_alias_in_tree() {
let home = tempfile::tempdir().unwrap();
let skills_root = home.path().join(".openhuman").join("skills");
std::fs::create_dir_all(&skills_root).unwrap();
let real_dir = skills_root.join("real");
write(
&real_dir.join("SKILL.md"),
"---\nname: real\ndescription: in tree\n---\n",
);
std::os::unix::fs::symlink(&real_dir, skills_root.join("alias")).unwrap();
let err = uninstall_skill(
UninstallSkillParams {
name: "alias".into(),
},
Some(home.path()),
)
.unwrap_err();
assert!(
err.contains("symlinked alias"),
"in-tree alias must be rejected by preflight, got: {err}"
);
assert!(
real_dir.join("SKILL.md").exists(),
"real skill behind the alias must survive"
);
}
/// A symlinked skills *root* (`~/.openhuman/skills -> elsewhere`) must
/// be refused before canonicalisation, since `canonicalize` would
/// resolve it to the target and the `starts_with` guard would then
/// compare against the resolved target, not the nominal root.
#[cfg(unix)]
#[test]
fn uninstall_skill_rejects_symlinked_skills_root() {
let home = tempfile::tempdir().unwrap();
let real_root = tempfile::tempdir().unwrap();
let real_skills = real_root.path().join("skills");
std::fs::create_dir_all(&real_skills).unwrap();
write(
&real_skills.join("real").join("SKILL.md"),
"---\nname: real\ndescription: in real root\n---\n",
);
std::fs::create_dir_all(home.path().join(".openhuman")).unwrap();
std::os::unix::fs::symlink(&real_skills, home.path().join(".openhuman").join("skills"))
.unwrap();
let err = uninstall_skill(
UninstallSkillParams {
name: "real".into(),
},
Some(home.path()),
)
.unwrap_err();
assert!(
err.contains("skills root") && err.contains("symlink"),
"symlinked skills root must be refused, got: {err}"
);
assert!(
real_skills.join("real").join("SKILL.md").exists(),
"target must survive"
);
}
}
+83 -2
View File
@@ -27,7 +27,8 @@ use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::Config;
use crate::openhuman::skills::ops::{
create_skill, discover_skills, install_skill_from_url, is_workspace_trusted,
read_skill_resource, CreateSkillParams, InstallSkillFromUrlParams, Skill, SkillScope,
read_skill_resource, uninstall_skill, CreateSkillParams, InstallSkillFromUrlParams, Skill,
SkillScope, UninstallSkillParams,
};
use crate::rpc::RpcOutcome;
@@ -96,8 +97,17 @@ struct SkillSummary {
impl From<Skill> for SkillSummary {
fn from(s: Skill) -> Self {
// `id` is the on-disk slug the uninstall RPC resolves against.
// Prefer `dir_name`, but fall back to `name` for back-compat on
// deserialised `Skill` values written before `dir_name` existed
// (default empty string).
let id = if s.dir_name.is_empty() {
s.name.clone()
} else {
s.dir_name.clone()
};
SkillSummary {
id: s.name.clone(),
id,
name: s.name,
description: s.description,
version: s.version,
@@ -160,12 +170,20 @@ struct SkillsInstallFromUrlResult {
new_skills: Vec<String>,
}
#[derive(Debug, Serialize)]
struct SkillsUninstallResult {
name: String,
removed_path: String,
scope: SkillScope,
}
pub fn all_skills_controller_schemas() -> Vec<ControllerSchema> {
vec![
skills_schemas("skills_list"),
skills_schemas("skills_read_resource"),
skills_schemas("skills_create"),
skills_schemas("skills_install_from_url"),
skills_schemas("skills_uninstall"),
]
}
@@ -187,6 +205,10 @@ pub fn all_skills_registered_controllers() -> Vec<RegisteredController> {
schema: skills_schemas("skills_install_from_url"),
handler: handle_skills_install_from_url,
},
RegisteredController {
schema: skills_schemas("skills_uninstall"),
handler: handle_skills_uninstall,
},
]
}
@@ -349,6 +371,37 @@ pub fn skills_schemas(function: &str) -> ControllerSchema {
},
],
},
"skills_uninstall" => ControllerSchema {
namespace: "skills",
function: "uninstall",
description: "Remove an installed user-scope SKILL.md skill from `~/.openhuman/skills/<name>/`. Only user-scope installs are supported; project-scope and legacy skills are read-only. Rejects path separators and traversal; canonicalises before delete.",
inputs: vec![FieldSchema {
name: "name",
ty: TypeSchema::String,
comment: "Exact on-disk slug of the installed skill — matches SkillSummary.id (the directory under ~/.openhuman/skills/), which may differ from the frontmatter display name in Skill.name.",
required: true,
}],
outputs: vec![
FieldSchema {
name: "name",
ty: TypeSchema::String,
comment: "Echo of the removed skill slug.",
required: true,
},
FieldSchema {
name: "removed_path",
ty: TypeSchema::String,
comment: "Canonical on-disk path that was deleted.",
required: true,
},
FieldSchema {
name: "scope",
ty: TypeSchema::String,
comment: "Scope the uninstall applied to. Always `user` today.",
required: true,
},
],
},
_ => ControllerSchema {
namespace: "skills",
function: "unknown",
@@ -487,6 +540,34 @@ fn handle_skills_install_from_url(params: Map<String, Value>) -> ControllerFutur
})
}
fn handle_skills_uninstall(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<UninstallSkillParams>(params)?;
tracing::debug!(name = %payload.name, "[skills][rpc] uninstall");
match uninstall_skill(payload, None) {
Ok(outcome) => {
tracing::debug!(
name = %outcome.name,
removed_path = %outcome.removed_path,
"[skills][rpc] uninstall: ok"
);
to_json(RpcOutcome::new(
SkillsUninstallResult {
name: outcome.name,
removed_path: outcome.removed_path,
scope: outcome.scope,
},
Vec::new(),
))
}
Err(err) => {
tracing::debug!(error = %err, "[skills][rpc] uninstall: rejected");
Err(err)
}
}
})
}
/// Resolve the active [`Config`]. Falls back to `Config::default()` with a
/// best-effort workspace directory if the persisted load times out or errors,
/// so headless diagnostics still work in partially-initialized environments.
+112
View File
@@ -2277,3 +2277,115 @@ async fn notification_settings_roundtrip_and_disabled_ingest_skip() {
mock_join.abort();
rpc_join.abort();
}
/// End-to-end coverage for `openhuman.skills_uninstall`.
///
/// Validates that the RPC method is registered, wire-decodes
/// `UninstallSkillParams`, resolves the slug against
/// `~/.openhuman/skills/<slug>/`, removes the directory on success, and
/// forwards the core error message verbatim for the two documented
/// failure modes (missing SKILL.md and path traversal). Previously only
/// the `uninstall_skill(...)` helper was tested — the wire layer
/// (controller registration, param decoding, response shape) was not.
#[tokio::test]
async fn skills_uninstall_rpc_e2e() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let skills_root = home.join(".openhuman").join("skills");
std::fs::create_dir_all(&skills_root).expect("mkdir skills root");
// Seed a skill whose on-disk slug differs from its frontmatter name —
// mirrors the bug CodeRabbit flagged for #781: the UI must send the
// slug (`SkillSummary.id` / directory name), not the display name.
let slug = "weather-helper";
let skill_dir = skills_root.join(slug);
std::fs::create_dir_all(&skill_dir).expect("mkdir skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: Weather Helper\ndescription: fetches local weather\n---\n# body\n",
)
.expect("write SKILL.md");
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
// --- success path ------------------------------------------------------
let ok = post_json_rpc(
&rpc_base,
6001,
"openhuman.skills_uninstall",
json!({ "name": slug }),
)
.await;
let ok_result = assert_no_jsonrpc_error(&ok, "skills_uninstall success");
assert_eq!(
ok_result.get("name").and_then(Value::as_str),
Some(slug),
"response echoes the slug we passed"
);
assert_eq!(
ok_result.get("scope").and_then(Value::as_str),
Some("user"),
"uninstall is user-scope only"
);
let removed_path = ok_result
.get("removed_path")
.and_then(Value::as_str)
.expect("removed_path in response");
assert!(
removed_path.ends_with(slug)
|| removed_path.contains(&format!("skills{}{slug}", std::path::MAIN_SEPARATOR)),
"removed_path should reference the slug dir, got: {removed_path}"
);
assert!(
!skill_dir.exists(),
"directory must be gone after uninstall"
);
// --- not-installed path: core error forwarded verbatim ----------------
let missing = post_json_rpc(
&rpc_base,
6002,
"openhuman.skills_uninstall",
json!({ "name": "does-not-exist" }),
)
.await;
let err = missing
.get("error")
.unwrap_or_else(|| panic!("expected error, got {missing}"));
let err_msg = err
.get("message")
.and_then(Value::as_str)
.or_else(|| err.get("data").and_then(Value::as_str))
.unwrap_or("");
assert!(
err_msg.contains("not installed") || err.to_string().contains("not installed"),
"expected verbatim 'not installed' error, got: {err}"
);
// --- path-traversal path: core error forwarded verbatim ---------------
let traversal = post_json_rpc(
&rpc_base,
6003,
"openhuman.skills_uninstall",
json!({ "name": "../etc" }),
)
.await;
let traversal_err = traversal
.get("error")
.unwrap_or_else(|| panic!("expected error, got {traversal}"));
let traversal_msg = traversal_err.to_string();
assert!(
traversal_msg.contains("path separators")
|| traversal_msg.contains("path escapes")
|| traversal_msg.contains("not installed"),
"expected traversal rejection error, got: {traversal_err}"
);
rpc_join.abort();
}