mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: add audited admin skill hard delete (#3167)
This commit is contained in:
@@ -55,6 +55,7 @@ skills|skill
|
||||
`bun run admin -- skills --help` exposes:
|
||||
|
||||
```text
|
||||
hard-delete <skill>
|
||||
unhide <slug>
|
||||
revoke-version <slug>
|
||||
rescan <slug>
|
||||
@@ -65,6 +66,8 @@ triage-report <report-id>
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
bun run admin -- skills hard-delete @owner/<slug> --reason "<reason>" # dry-run
|
||||
bun run admin -- skills hard-delete @owner/<slug> --reason "<reason>" --apply --confirm "<token>" --yes
|
||||
bun run admin -- skills unhide <slug> --reason "<reason>" --yes
|
||||
bun run admin -- skills revoke-version <slug> --version <version> --reason "<reason>" --yes
|
||||
bun run admin -- skills rescan <slug> --reason "<reason>" --yes
|
||||
@@ -72,7 +75,11 @@ bun run admin -- skills reports --status open
|
||||
bun run admin -- skills triage-report <report-id> --status confirmed --action hide --note "<note>" --yes
|
||||
```
|
||||
|
||||
Pass `--owner <handle>` to `revoke-version` when more than one publisher uses the same slug.
|
||||
`hard-delete` requires an owner-qualified ref and defaults to a dry-run. Apply
|
||||
only with the exact confirmation token returned by that dry-run.
|
||||
|
||||
Pass `--owner <handle>` to `revoke-version` when more than one publisher uses
|
||||
the same slug.
|
||||
|
||||
### Users
|
||||
|
||||
@@ -193,6 +200,7 @@ only after admin auth succeeds.
|
||||
## Verification
|
||||
|
||||
- For skills, inspect the page/API status after `skills unhide`.
|
||||
- For `skills hard-delete`, verify owner-scoped page/API reads return not found.
|
||||
- For users, prefer user search/admin surfaces for target accounts where
|
||||
available.
|
||||
- For orgs and packages, use the public publisher/plugin pages and the relevant
|
||||
|
||||
@@ -7366,6 +7366,86 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(await response.text()).toBe(message);
|
||||
});
|
||||
|
||||
it("skill hard-delete dry-runs through the admin-only API", async () => {
|
||||
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:1";
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
skillId: "skills:1",
|
||||
slug: "demo",
|
||||
ownerHandle: "openclaw",
|
||||
displayName: "Demo",
|
||||
dryRun: true,
|
||||
scheduled: false,
|
||||
confirmationToken: generated_token_reference,
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/hard-delete", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
body: JSON.stringify({
|
||||
ownerHandle: "openclaw",
|
||||
reason: "Owner-requested cleanup",
|
||||
dryRun: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
dryRun: true,
|
||||
scheduled: false,
|
||||
confirmationToken: generated_token_reference,
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
(internal as unknown as { skills: Record<string, unknown> }).skills
|
||||
.hardDeleteForAdminInternal,
|
||||
{
|
||||
actorUserId: "users:admin",
|
||||
slug: "demo",
|
||||
ownerHandle: "openclaw",
|
||||
reason: "Owner-requested cleanup",
|
||||
dryRun: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("skill hard-delete forbids non-admin API tokens", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
throw new Error("should not hard-delete");
|
||||
});
|
||||
|
||||
const response = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/hard-delete", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
body: JSON.stringify({
|
||||
ownerHandle: "openclaw",
|
||||
reason: "Owner-requested cleanup",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.text()).resolves.toBe("Admin role required.");
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skill rescan enqueues owner-authorized ClawScan jobs", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ApiRoutes,
|
||||
ApiV1SkillBulkRescanBatchRequestSchema,
|
||||
ApiV1SkillBulkRescanStatusRequestSchema,
|
||||
ApiV1SkillHardDeleteRequestSchema,
|
||||
ApiV1SkillRepairVtPendingRequestSchema,
|
||||
ApiV1SkillScanBatchRequestSchema,
|
||||
ApiV1SkillScanBatchStatusRequestSchema,
|
||||
@@ -353,6 +354,7 @@ const internalRefs = internal as unknown as {
|
||||
getSkillBySlugInternal: unknown;
|
||||
getVersionByIdInternal: unknown;
|
||||
getVersionBySkillAndVersionInternal: unknown;
|
||||
hardDeleteForAdminInternal: unknown;
|
||||
reportSkillForUserInternal: unknown;
|
||||
listSkillReportsInternal: unknown;
|
||||
triageSkillReportForUserInternal: unknown;
|
||||
@@ -3043,6 +3045,38 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.length === 2 && action === "hard-delete") {
|
||||
if (!slug) return text("Slug required", 400, rate.headers);
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
const admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
try {
|
||||
const payload = parseArk(
|
||||
ApiV1SkillHardDeleteRequestSchema,
|
||||
await request.json(),
|
||||
"Skill hard-delete payload",
|
||||
) as {
|
||||
ownerHandle: string;
|
||||
reason: string;
|
||||
dryRun?: boolean;
|
||||
confirmationToken?: string;
|
||||
};
|
||||
const result = await runMutationRef(ctx, internalRefs.skills.hardDeleteForAdminInternal, {
|
||||
actorUserId: auth.userId,
|
||||
slug,
|
||||
ownerHandle: payload.ownerHandle,
|
||||
reason: payload.reason,
|
||||
...(payload.dryRun !== undefined ? { dryRun: payload.dryRun } : {}),
|
||||
...(payload.confirmationToken ? { confirmationToken: payload.confirmationToken } : {}),
|
||||
});
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) return text("Invalid JSON", 400, rate.headers);
|
||||
return skillHardDeleteErrorToResponse(error, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
segments[0] === "-" &&
|
||||
segments[1] === "reports" &&
|
||||
@@ -3286,6 +3320,15 @@ function skillVersionModerationErrorToResponse(error: unknown, headers: HeadersI
|
||||
return text(message, 400, headers);
|
||||
}
|
||||
|
||||
function skillHardDeleteErrorToResponse(error: unknown, headers: HeadersInit) {
|
||||
const message = error instanceof Error ? error.message : "Skill hard delete failed";
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("unauthorized")) return text(message, 401, headers);
|
||||
if (lower.includes("forbidden")) return text(message, 403, headers);
|
||||
if (lower.includes("not found")) return text(message, 404, headers);
|
||||
return text(message, 400, headers);
|
||||
}
|
||||
|
||||
function skillRescanErrorToResponse(error: unknown, headers: HeadersInit) {
|
||||
const message = error instanceof Error ? error.message : "Skill rescan failed";
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { hardDeleteForAdminInternal } from "./skills";
|
||||
|
||||
type HardDeleteForAdminArgs = {
|
||||
actorUserId: string;
|
||||
slug: string;
|
||||
ownerHandle: string;
|
||||
reason: string;
|
||||
dryRun?: boolean;
|
||||
confirmationToken?: string;
|
||||
};
|
||||
|
||||
type HardDeleteForAdminResult = {
|
||||
ok: true;
|
||||
skillId: string;
|
||||
slug: string;
|
||||
ownerHandle: string;
|
||||
displayName: string;
|
||||
dryRun: boolean;
|
||||
scheduled: boolean;
|
||||
confirmationToken: string;
|
||||
};
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const hardDeleteForAdminHandler = (
|
||||
hardDeleteForAdminInternal as unknown as WrappedHandler<
|
||||
HardDeleteForAdminArgs,
|
||||
HardDeleteForAdminResult
|
||||
>
|
||||
)._handler;
|
||||
|
||||
function makeCtx() {
|
||||
const skill = {
|
||||
_id: "skills:demo",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
softDeletedAt: 1_000,
|
||||
hiddenAt: 1_000,
|
||||
hiddenBy: "users:moderator",
|
||||
moderationStatus: "removed",
|
||||
};
|
||||
const insert = vi.fn();
|
||||
const scheduler = { runAfter: vi.fn() };
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publishers:openclaw",
|
||||
kind: "user",
|
||||
handle: "openclaw",
|
||||
linkedUserId: "users:owner",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => skill,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) =>
|
||||
id === "users:admin"
|
||||
? {
|
||||
_id: "users:admin",
|
||||
role: "admin",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
insert,
|
||||
patch: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
query,
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
scheduler,
|
||||
} as never;
|
||||
return { ctx, insert, scheduler };
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
actorUserId: "users:admin",
|
||||
slug: "demo",
|
||||
ownerHandle: "openclaw",
|
||||
reason: "Owner-requested cleanup",
|
||||
};
|
||||
|
||||
describe("hardDeleteForAdminInternal", () => {
|
||||
it("returns an exact confirmation token without mutating during dry-run", async () => {
|
||||
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
|
||||
const { ctx, insert, scheduler } = makeCtx();
|
||||
|
||||
const result = await hardDeleteForAdminHandler(ctx, baseArgs);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
skillId: "skills:demo",
|
||||
slug: "demo",
|
||||
ownerHandle: "openclaw",
|
||||
displayName: "Demo",
|
||||
dryRun: true,
|
||||
scheduled: false,
|
||||
confirmationToken: generated_token_reference,
|
||||
});
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects apply without the exact dry-run confirmation token", async () => {
|
||||
const { ctx, insert, scheduler } = makeCtx();
|
||||
|
||||
await expect(
|
||||
hardDeleteForAdminHandler(ctx, {
|
||||
...baseArgs,
|
||||
dryRun: false,
|
||||
confirmationToken: "wrong",
|
||||
}),
|
||||
).rejects.toThrow('Confirmation token must be "hard-delete-skill:@openclaw/demo:skills:demo"');
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("audits the reason and schedules the existing batched cleanup", async () => {
|
||||
const { ctx, insert, scheduler } = makeCtx();
|
||||
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
|
||||
|
||||
const result = await hardDeleteForAdminHandler(ctx, {
|
||||
...baseArgs,
|
||||
dryRun: false,
|
||||
confirmationToken: generated_token_reference,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dryRun: false,
|
||||
scheduled: true,
|
||||
confirmationToken: generated_token_reference,
|
||||
});
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"auditLogs",
|
||||
expect.objectContaining({
|
||||
actorUserId: "users:admin",
|
||||
action: "skill.hard_delete.requested",
|
||||
targetType: "skill",
|
||||
targetId: "skills:demo",
|
||||
metadata: {
|
||||
slug: "demo",
|
||||
ownerHandle: "openclaw",
|
||||
reason: "Owner-requested cleanup",
|
||||
source: "clawhub-admin",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
skillId: "skills:demo",
|
||||
actorUserId: "users:admin",
|
||||
phase: "fingerprints",
|
||||
source: "admin",
|
||||
reason: "Owner-requested cleanup",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+86
-1
@@ -1615,6 +1615,7 @@ type HardDeleteSource = "admin" | "account.delete" | "publisher.delete";
|
||||
type HardDeleteScope = {
|
||||
source?: HardDeleteSource;
|
||||
ownerPublisherId?: Id<"publishers">;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
const hardDeleteSourceValidator = v.optional(
|
||||
@@ -1639,6 +1640,7 @@ async function scheduleHardDelete(
|
||||
phase,
|
||||
source: scope.source,
|
||||
ownerPublisherId: scope.ownerPublisherId,
|
||||
reason: scope.reason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1916,7 +1918,11 @@ async function hardDeleteSkillStep(
|
||||
action: "skill.hard_delete",
|
||||
targetType: "skill",
|
||||
targetId: skill._id,
|
||||
metadata: { slug: skill.slug },
|
||||
metadata: {
|
||||
slug: skill.slug,
|
||||
source: scope.source ?? "admin",
|
||||
...(scope.reason ? { reason: scope.reason } : {}),
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
return;
|
||||
@@ -11925,6 +11931,83 @@ export const hardDelete = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const hardDeleteForAdminInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
slug: v.string(),
|
||||
ownerHandle: v.string(),
|
||||
reason: v.string(),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
confirmationToken: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
|
||||
const slug = normalizeSkillSlugKey(args.slug);
|
||||
const ownerHandle = normalizePublisherHandle(args.ownerHandle);
|
||||
const reason = args.reason.trim();
|
||||
if (!slug) throw new ConvexError("Slug required");
|
||||
if (!ownerHandle) throw new ConvexError("Owner handle required");
|
||||
if (!reason) throw new ConvexError("Reason is required");
|
||||
if (reason.length > 500) throw new ConvexError("Reason too long (max 500 chars)");
|
||||
|
||||
const resolved = await resolveSkillBySlugOrAliasForOwner(ctx, slug, ownerHandle, {
|
||||
includeSoftDeleted: true,
|
||||
});
|
||||
const skill = resolved.skill;
|
||||
if (!skill) throw new ConvexError("Skill not found");
|
||||
|
||||
const generated_token_reference = `hard-delete-skill:@${ownerHandle}/${skill.slug}:${skill._id}`;
|
||||
const baseResult = {
|
||||
ok: true as const,
|
||||
skillId: skill._id,
|
||||
slug: skill.slug,
|
||||
ownerHandle,
|
||||
displayName: skill.displayName,
|
||||
confirmationToken: generated_token_reference,
|
||||
};
|
||||
const dryRun = args.dryRun !== false;
|
||||
if (dryRun) {
|
||||
return {
|
||||
...baseResult,
|
||||
dryRun: true,
|
||||
scheduled: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (args.confirmationToken !== generated_token_reference) {
|
||||
throw new ConvexError(`Confirmation token must be "${generated_token_reference}"`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "skill.hard_delete.requested",
|
||||
targetType: "skill",
|
||||
targetId: skill._id,
|
||||
metadata: {
|
||||
slug: skill.slug,
|
||||
ownerHandle,
|
||||
reason,
|
||||
source: "clawhub-admin",
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
await hardDeleteSkillStep(ctx, skill, args.actorUserId, "versions", {
|
||||
source: "admin",
|
||||
reason,
|
||||
});
|
||||
|
||||
return {
|
||||
...baseResult,
|
||||
dryRun: false,
|
||||
scheduled: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const hardDeleteInternal = internalMutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
@@ -11932,6 +12015,7 @@ export const hardDeleteInternal = internalMutation({
|
||||
phase: v.optional(v.string()),
|
||||
source: hardDeleteSourceValidator,
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
@@ -11965,6 +12049,7 @@ export const hardDeleteInternal = internalMutation({
|
||||
await hardDeleteSkillStep(ctx, skill, args.actorUserId, phase, {
|
||||
source,
|
||||
ownerPublisherId: args.ownerPublisherId,
|
||||
reason: args.reason,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,6 +116,7 @@ Package moderation and operations:
|
||||
bun run admin -- skills reports [--status open|confirmed|dismissed|all]
|
||||
bun run admin -- skills feature <slug|@owner/slug> [--json]
|
||||
bun run admin -- skills unfeature <slug|@owner/slug> [--json]
|
||||
bun run admin -- skills hard-delete @owner/slug --reason <text> [--apply --confirm <token> --yes] [--json]
|
||||
bun run admin -- skills rescan <slug> [--version <version>] [--yes] [--json]
|
||||
bun run admin -- skills unhide <slug> --reason <text> [--yes]
|
||||
bun run admin -- skills triage-report <report-id> --status open|confirmed|dismissed [--note <text>] [--action none|hide] [--yes]
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
cmdSetPromotionStatus,
|
||||
cmdUpdatePromotion,
|
||||
} from "./commands/promotions.js";
|
||||
import { cmdHardDeleteSkill } from "./commands/skills.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("clawhub-admin")
|
||||
@@ -793,6 +794,20 @@ function registerPluginOperations(command: Command) {
|
||||
function registerSkillModerationCommands(command: Command) {
|
||||
registerFeaturedCommands(command, "skill");
|
||||
|
||||
command
|
||||
.command("hard-delete")
|
||||
.description("Permanently delete one owner-qualified skill and all related history")
|
||||
.argument("<skill>", "Owner-qualified skill ref: @owner/slug")
|
||||
.requiredOption("--reason <reason>", "Audit reason")
|
||||
.option("--apply", "Schedule deletion; defaults to dry-run")
|
||||
.option("--confirm <token>", "Confirmation token returned by the dry-run")
|
||||
.option("--yes", "Skip confirmation for --apply")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (skill, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdHardDeleteSkill(opts, skill, options, isInputAllowed());
|
||||
});
|
||||
|
||||
command
|
||||
.command("revoke-version")
|
||||
.description("Permanently remove one skill version from public access")
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../clawhub/test/cliCommandTestKit.js";
|
||||
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
vi.mock("../../../clawhub/src/cli/authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../../../clawhub/src/cli/registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../../clawhub/src/http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdHardDeleteSkill } = await import("./skills");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("cmdHardDeleteSkill", () => {
|
||||
it("dry-runs an owner-qualified skill by default", async () => {
|
||||
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
skillId: "skills:demo",
|
||||
slug: "demo",
|
||||
ownerHandle: "openclaw",
|
||||
displayName: "Demo",
|
||||
dryRun: true,
|
||||
scheduled: false,
|
||||
confirmationToken: generated_token_reference,
|
||||
});
|
||||
|
||||
const result = await cmdHardDeleteSkill(
|
||||
makeGlobalOpts(),
|
||||
"@OpenClaw/Demo",
|
||||
{ reason: "Owner-requested cleanup", json: true },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dryRun: true,
|
||||
scheduled: false,
|
||||
ownerHandle: "openclaw",
|
||||
slug: "demo",
|
||||
});
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/skills/demo/hard-delete",
|
||||
token: "tkn",
|
||||
body: {
|
||||
ownerHandle: "openclaw",
|
||||
reason: "Owner-requested cleanup",
|
||||
dryRun: true,
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires an owner-qualified skill ref", async () => {
|
||||
await expect(
|
||||
cmdHardDeleteSkill(makeGlobalOpts(), "demo", { reason: "Cleanup" }, false),
|
||||
).rejects.toThrow(/owner-qualified/i);
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a confirmation token when applying", async () => {
|
||||
await expect(
|
||||
cmdHardDeleteSkill(
|
||||
makeGlobalOpts(),
|
||||
"@openclaw/demo",
|
||||
{ reason: "Cleanup", apply: true, yes: true },
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow(/--confirm required/i);
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies with the dry-run confirmation token and disables retries", async () => {
|
||||
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
skillId: "skills:demo",
|
||||
slug: "demo",
|
||||
ownerHandle: "openclaw",
|
||||
displayName: "Demo",
|
||||
dryRun: false,
|
||||
scheduled: true,
|
||||
confirmationToken: generated_token_reference,
|
||||
});
|
||||
|
||||
await cmdHardDeleteSkill(
|
||||
makeGlobalOpts(),
|
||||
"@openclaw/demo",
|
||||
{
|
||||
reason: "Owner-requested cleanup",
|
||||
apply: true,
|
||||
confirm: generated_token_reference,
|
||||
yes: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/skills/demo/hard-delete",
|
||||
retryCount: 0,
|
||||
body: {
|
||||
ownerHandle: "openclaw",
|
||||
reason: "Owner-requested cleanup",
|
||||
dryRun: false,
|
||||
confirmationToken: generated_token_reference,
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { requireAuthToken } from "../../../clawhub/src/cli/authToken.js";
|
||||
import { getRegistry } from "../../../clawhub/src/cli/registry.js";
|
||||
import type { GlobalOpts } from "../../../clawhub/src/cli/types.js";
|
||||
import {
|
||||
createCrabLoader,
|
||||
fail,
|
||||
formatError,
|
||||
isInteractive,
|
||||
promptConfirm,
|
||||
} from "../../../clawhub/src/cli/ui.js";
|
||||
import { apiRequest } from "../../../clawhub/src/http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SkillHardDeleteResponseSchema,
|
||||
} from "../../../clawhub/src/schema/index.js";
|
||||
|
||||
type SkillHardDeleteOptions = {
|
||||
reason?: string;
|
||||
apply?: boolean;
|
||||
confirm?: string;
|
||||
json?: boolean;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
type OwnerQualifiedSkillRef = {
|
||||
ownerHandle: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export async function cmdHardDeleteSkill(
|
||||
opts: GlobalOpts,
|
||||
skillRef: string,
|
||||
options: SkillHardDeleteOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const ref = parseOwnerQualifiedSkillRef(skillRef);
|
||||
const label = `@${ref.ownerHandle}/${ref.slug}`;
|
||||
const reason = options.reason?.trim();
|
||||
if (!reason) fail("--reason required");
|
||||
const dryRun = options.apply !== true;
|
||||
const confirmationToken = options.confirm?.trim();
|
||||
if (!dryRun && !confirmationToken) fail("--confirm required when using --apply");
|
||||
|
||||
if (!dryRun && !options.yes) {
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false;
|
||||
if (!allowPrompt) fail("Pass --yes (no input)");
|
||||
const confirmed = await promptConfirm(
|
||||
`Permanently hard-delete ${label} and all related history? (cannot be undone)`,
|
||||
);
|
||||
if (!confirmed) return undefined;
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = options.json
|
||||
? null
|
||||
: createCrabLoader(`${dryRun ? "Planning hard delete for" : "Hard-deleting"} ${label}`);
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(ref.slug)}/hard-delete`,
|
||||
token,
|
||||
...(dryRun ? {} : { retryCount: 0 }),
|
||||
body: {
|
||||
ownerHandle: ref.ownerHandle,
|
||||
reason,
|
||||
dryRun,
|
||||
...(confirmationToken ? { confirmationToken } : {}),
|
||||
},
|
||||
},
|
||||
ApiV1SkillHardDeleteResponseSchema,
|
||||
);
|
||||
|
||||
spinner?.succeed(
|
||||
result.scheduled
|
||||
? `Scheduled hard delete for @${result.ownerHandle}/${result.slug}`
|
||||
: `Dry run OK for @${result.ownerHandle}/${result.slug}: pass --apply --confirm ${result.confirmationToken}`,
|
||||
);
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOwnerQualifiedSkillRef(value: string): OwnerQualifiedSkillRef {
|
||||
const ref = value.trim();
|
||||
const slashIndex = ref.indexOf("/");
|
||||
if (slashIndex < 0 || ref.indexOf("/", slashIndex + 1) >= 0) {
|
||||
fail("Use an owner-qualified skill ref: @owner/slug");
|
||||
}
|
||||
|
||||
const ownerHandle = ref.slice(0, slashIndex).trim().replace(/^@+/, "").toLowerCase();
|
||||
const slug = ref
|
||||
.slice(slashIndex + 1)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (
|
||||
!ownerHandle ||
|
||||
!slug ||
|
||||
ownerHandle.includes("\\") ||
|
||||
ownerHandle.includes("..") ||
|
||||
slug.includes("\\") ||
|
||||
slug.includes("..")
|
||||
) {
|
||||
fail(`Invalid skill ref: ${value}`);
|
||||
}
|
||||
return { ownerHandle, slug };
|
||||
}
|
||||
@@ -647,6 +647,26 @@ export const ApiV1SkillRescanResponseSchema = type({
|
||||
});
|
||||
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillHardDeleteRequestSchema = type({
|
||||
ownerHandle: "string",
|
||||
reason: "string",
|
||||
dryRun: "boolean?",
|
||||
confirmationToken: "string?",
|
||||
});
|
||||
export type ApiV1SkillHardDeleteRequest = (typeof ApiV1SkillHardDeleteRequestSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillHardDeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
skillId: "string",
|
||||
slug: "string",
|
||||
ownerHandle: "string",
|
||||
displayName: "string",
|
||||
dryRun: "boolean",
|
||||
scheduled: "boolean",
|
||||
confirmationToken: "string",
|
||||
});
|
||||
export type ApiV1SkillHardDeleteResponse = (typeof ApiV1SkillHardDeleteResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"');
|
||||
export type ApiV1SkillScanStatus = (typeof ApiV1SkillScanStatusSchema)[inferred];
|
||||
|
||||
|
||||
Vendored
+18
@@ -502,6 +502,24 @@ export declare const ApiV1SkillRescanResponseSchema: import("arktype/internal/va
|
||||
alreadyQueued: boolean;
|
||||
}, {}>;
|
||||
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
|
||||
export declare const ApiV1SkillHardDeleteRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ownerHandle: string;
|
||||
reason: string;
|
||||
dryRun?: boolean | undefined;
|
||||
confirmationToken?: string | undefined;
|
||||
}, {}>;
|
||||
export type ApiV1SkillHardDeleteRequest = (typeof ApiV1SkillHardDeleteRequestSchema)[inferred];
|
||||
export declare const ApiV1SkillHardDeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
skillId: string;
|
||||
slug: string;
|
||||
ownerHandle: string;
|
||||
displayName: string;
|
||||
dryRun: boolean;
|
||||
scheduled: boolean;
|
||||
confirmationToken: string;
|
||||
}, {}>;
|
||||
export type ApiV1SkillHardDeleteResponse = (typeof ApiV1SkillHardDeleteResponseSchema)[inferred];
|
||||
export declare const ApiV1SkillScanStatusSchema: import("arktype/internal/variants/string.ts").StringType<"failed" | "queued" | "running" | "succeeded", {}>;
|
||||
export type ApiV1SkillScanStatus = (typeof ApiV1SkillScanStatusSchema)[inferred];
|
||||
export declare const ApiV1SkillScanSourceSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
|
||||
Vendored
+16
@@ -467,6 +467,22 @@ export const ApiV1SkillRescanResponseSchema = type({
|
||||
scheduled: "boolean",
|
||||
alreadyQueued: "boolean",
|
||||
});
|
||||
export const ApiV1SkillHardDeleteRequestSchema = type({
|
||||
ownerHandle: "string",
|
||||
reason: "string",
|
||||
dryRun: "boolean?",
|
||||
confirmationToken: "string?",
|
||||
});
|
||||
export const ApiV1SkillHardDeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
skillId: "string",
|
||||
slug: "string",
|
||||
ownerHandle: "string",
|
||||
displayName: "string",
|
||||
dryRun: "boolean",
|
||||
scheduled: "boolean",
|
||||
confirmationToken: "string",
|
||||
});
|
||||
export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"');
|
||||
export const ApiV1SkillScanSourceSchema = type({
|
||||
kind: '"upload"',
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -11,6 +11,7 @@ import {
|
||||
} from "./packages";
|
||||
import {
|
||||
ApiSearchResponseSchema,
|
||||
ApiV1SkillHardDeleteResponseSchema,
|
||||
ApiV1SkillInstallResolveResponseSchema,
|
||||
ApiV1SkillRescanResponseSchema,
|
||||
ApiV1SearchResponseSchema,
|
||||
@@ -23,6 +24,27 @@ import {
|
||||
} from "./schemas";
|
||||
|
||||
describe("clawhub-schema", () => {
|
||||
it("parses skill hard-delete responses", () => {
|
||||
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
|
||||
const response = parseArk(
|
||||
ApiV1SkillHardDeleteResponseSchema,
|
||||
{
|
||||
ok: true,
|
||||
skillId: "skills:demo",
|
||||
slug: "demo",
|
||||
ownerHandle: "openclaw",
|
||||
displayName: "Demo",
|
||||
dryRun: true,
|
||||
scheduled: false,
|
||||
confirmationToken: generated_token_reference,
|
||||
},
|
||||
"Skill hard-delete response",
|
||||
);
|
||||
|
||||
expect(response.ownerHandle).toBe("openclaw");
|
||||
expect(response.scheduled).toBe(false);
|
||||
});
|
||||
|
||||
it("parses lockfile records", () => {
|
||||
const lock = parseArk(
|
||||
LockfileSchema,
|
||||
|
||||
@@ -547,6 +547,26 @@ export const ApiV1SkillRescanResponseSchema = type({
|
||||
});
|
||||
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillHardDeleteRequestSchema = type({
|
||||
ownerHandle: "string",
|
||||
reason: "string",
|
||||
dryRun: "boolean?",
|
||||
confirmationToken: "string?",
|
||||
});
|
||||
export type ApiV1SkillHardDeleteRequest = (typeof ApiV1SkillHardDeleteRequestSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillHardDeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
skillId: "string",
|
||||
slug: "string",
|
||||
ownerHandle: "string",
|
||||
displayName: "string",
|
||||
dryRun: "boolean",
|
||||
scheduled: "boolean",
|
||||
confirmationToken: "string",
|
||||
});
|
||||
export type ApiV1SkillHardDeleteResponse = (typeof ApiV1SkillHardDeleteResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"');
|
||||
export type ApiV1SkillScanStatus = (typeof ApiV1SkillScanStatusSchema)[inferred];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user