mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: add skill version revocation (#3049)
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
cmdRecoverPersonalPublisher,
|
||||
cmdReclassifyBan,
|
||||
cmdRepairVtPendingSkills,
|
||||
cmdRevokeSkillVersion,
|
||||
cmdRescanAllSkills,
|
||||
cmdRescanSkill,
|
||||
cmdSetRole,
|
||||
@@ -772,6 +773,20 @@ function registerPluginOperations(command: Command) {
|
||||
}
|
||||
|
||||
function registerSkillModerationCommands(command: Command) {
|
||||
command
|
||||
.command("revoke-version")
|
||||
.description("Permanently remove one skill version from public access")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.requiredOption("--version <version>", "Exact version to revoke")
|
||||
.requiredOption("--reason <reason>", "Audit reason")
|
||||
.option("--owner <handle>", "Owner handle when the slug is shared")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdRevokeSkillVersion(opts, slug, options, isInputAllowed());
|
||||
});
|
||||
|
||||
command
|
||||
.command("unhide")
|
||||
.description("Manually restore a hidden skill after moderator review")
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
cmdRecoverPersonalPublisher,
|
||||
cmdReclassifyBan,
|
||||
cmdRepairVtPendingSkills,
|
||||
cmdRevokeSkillVersion,
|
||||
cmdRescanAllSkills,
|
||||
cmdRescanSkill,
|
||||
cmdSetRole,
|
||||
@@ -201,6 +202,94 @@ describe("cmdRescanSkill", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdRevokeSkillVersion", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(
|
||||
cmdRevokeSkillVersion(
|
||||
makeGlobalOpts(),
|
||||
"demo",
|
||||
{ version: "1.0.0", reason: "confirmed unsafe artifact" },
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow(/--yes/i);
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts an exact-version revocation request", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:1",
|
||||
alreadyRevoked: false,
|
||||
replacementVersion: null,
|
||||
skillHidden: true,
|
||||
});
|
||||
|
||||
const result = await cmdRevokeSkillVersion(
|
||||
makeGlobalOpts(),
|
||||
"Demo",
|
||||
{ version: "1.0.0", reason: "confirmed unsafe artifact", yes: true },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
skillHidden: true,
|
||||
});
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/skills/demo/versions/1.0.0/moderation",
|
||||
token: "tkn",
|
||||
body: { state: "revoked", reason: "confirmed unsafe artifact" },
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes an owner qualifier for duplicate slugs", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:1",
|
||||
alreadyRevoked: false,
|
||||
replacementVersion: null,
|
||||
skillHidden: true,
|
||||
});
|
||||
|
||||
await cmdRevokeSkillVersion(
|
||||
makeGlobalOpts(),
|
||||
"demo",
|
||||
{
|
||||
version: "1.0.0",
|
||||
reason: "confirmed unsafe artifact",
|
||||
owner: "@publisher",
|
||||
yes: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
state: "revoked",
|
||||
reason: "confirmed unsafe artifact",
|
||||
ownerHandle: "publisher",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdRescanAllSkills", () => {
|
||||
it("requires --yes for real runs when input is disabled", async () => {
|
||||
await expect(cmdRescanAllSkills(makeGlobalOpts(), {}, false)).rejects.toThrow(/--yes/i);
|
||||
|
||||
@@ -20,11 +20,71 @@ import {
|
||||
ApiV1SkillScanBatchStatusResponseSchema,
|
||||
ApiV1SkillScanSubmitResponseSchema,
|
||||
ApiV1SkillRepairVtPendingResponseSchema,
|
||||
ApiV1SkillVersionRevokeResponseSchema,
|
||||
ApiV1UnbanUserResponseSchema,
|
||||
ApiV1UserSearchResponseSchema,
|
||||
parseArk,
|
||||
} from "../../../clawhub/src/schema/index.js";
|
||||
|
||||
export async function cmdRevokeSkillVersion(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: {
|
||||
version?: string;
|
||||
reason?: string;
|
||||
owner?: string;
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
},
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = slugArg.trim().toLowerCase();
|
||||
if (!slug) fail("Skill slug required");
|
||||
const version = options.version?.trim();
|
||||
if (!version) fail("--version required");
|
||||
const reason = options.reason?.trim();
|
||||
if (!reason) fail("--reason required");
|
||||
const ownerHandle = options.owner?.trim().replace(/^@+/, "") || undefined;
|
||||
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false;
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail("Pass --yes (no input)");
|
||||
const ok = await promptConfirm(`Revoke ${slug}@${version}? This version cannot be restored.`);
|
||||
if (!ok) return undefined;
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = options.json ? null : createCrabLoader(`Revoking ${slug}@${version}`);
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/moderation`,
|
||||
token,
|
||||
body: { state: "revoked", reason, ...(ownerHandle ? { ownerHandle } : {}) },
|
||||
},
|
||||
ApiV1SkillVersionRevokeResponseSchema,
|
||||
);
|
||||
const parsed = parseArk(
|
||||
ApiV1SkillVersionRevokeResponseSchema,
|
||||
result,
|
||||
"Skill version revocation response",
|
||||
);
|
||||
spinner?.succeed(
|
||||
parsed.alreadyRevoked
|
||||
? `OK. ${slug}@${version} was already revoked`
|
||||
: `OK. Revoked ${slug}@${version}`,
|
||||
);
|
||||
if (options.json) process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdBanUser(
|
||||
opts: GlobalOpts,
|
||||
identifierArg: string,
|
||||
|
||||
@@ -473,6 +473,26 @@ export const ApiV1SkillModerationResponseSchema = type({
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
export const SkillVersionRevokeRequestSchema = type({
|
||||
state: '"revoked"',
|
||||
reason: "string",
|
||||
ownerHandle: "string?",
|
||||
});
|
||||
export type SkillVersionRevokeRequest = (typeof SkillVersionRevokeRequestSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillVersionRevokeResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
version: "string",
|
||||
skillId: "string",
|
||||
versionId: "string",
|
||||
alreadyRevoked: "boolean",
|
||||
replacementVersion: "string|null",
|
||||
skillHidden: "boolean",
|
||||
});
|
||||
export type ApiV1SkillVersionRevokeResponse =
|
||||
(typeof ApiV1SkillVersionRevokeResponseSchema)[inferred];
|
||||
|
||||
export const SkillReportStatusSchema = type('"open"|"confirmed"|"dismissed"');
|
||||
export type SkillReportStatus = (typeof SkillReportStatusSchema)[inferred];
|
||||
export const SkillReportFinalActionSchema = type('"none"|"hide"');
|
||||
|
||||
Vendored
+17
@@ -347,6 +347,23 @@ export declare const ApiV1SkillModerationResponseSchema: import("arktype/interna
|
||||
legacyReason?: string | null | undefined;
|
||||
} | null;
|
||||
}, {}>;
|
||||
export declare const SkillVersionRevokeRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
state: "revoked";
|
||||
reason: string;
|
||||
ownerHandle?: string | undefined;
|
||||
}, {}>;
|
||||
export type SkillVersionRevokeRequest = (typeof SkillVersionRevokeRequestSchema)[inferred];
|
||||
export declare const ApiV1SkillVersionRevokeResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
slug: string;
|
||||
version: string;
|
||||
skillId: string;
|
||||
versionId: string;
|
||||
alreadyRevoked: boolean;
|
||||
replacementVersion: string | null;
|
||||
skillHidden: boolean;
|
||||
}, {}>;
|
||||
export type ApiV1SkillVersionRevokeResponse = (typeof ApiV1SkillVersionRevokeResponseSchema)[inferred];
|
||||
export declare const SkillReportStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "confirmed" | "dismissed", {}>;
|
||||
export type SkillReportStatus = (typeof SkillReportStatusSchema)[inferred];
|
||||
export declare const SkillReportFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"none" | "hide", {}>;
|
||||
|
||||
Vendored
+15
@@ -330,6 +330,21 @@ export const ApiV1SkillModerationResponseSchema = type({
|
||||
}).array(),
|
||||
}).or("null"),
|
||||
});
|
||||
export const SkillVersionRevokeRequestSchema = type({
|
||||
state: '"revoked"',
|
||||
reason: "string",
|
||||
ownerHandle: "string?",
|
||||
});
|
||||
export const ApiV1SkillVersionRevokeResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
version: "string",
|
||||
skillId: "string",
|
||||
versionId: "string",
|
||||
alreadyRevoked: "boolean",
|
||||
replacementVersion: "string|null",
|
||||
skillHidden: "boolean",
|
||||
});
|
||||
export const SkillReportStatusSchema = type('"open"|"confirmed"|"dismissed"');
|
||||
export const SkillReportFinalActionSchema = type('"none"|"hide"');
|
||||
export const SkillReportListStatusSchema = SkillReportStatusSchema.or('"all"');
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -373,6 +373,26 @@ export const ApiV1SkillModerationResponseSchema = type({
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
export const SkillVersionRevokeRequestSchema = type({
|
||||
state: '"revoked"',
|
||||
reason: "string",
|
||||
ownerHandle: "string?",
|
||||
});
|
||||
export type SkillVersionRevokeRequest = (typeof SkillVersionRevokeRequestSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillVersionRevokeResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
version: "string",
|
||||
skillId: "string",
|
||||
versionId: "string",
|
||||
alreadyRevoked: "boolean",
|
||||
replacementVersion: "string|null",
|
||||
skillHidden: "boolean",
|
||||
});
|
||||
export type ApiV1SkillVersionRevokeResponse =
|
||||
(typeof ApiV1SkillVersionRevokeResponseSchema)[inferred];
|
||||
|
||||
export const SkillReportStatusSchema = type('"open"|"confirmed"|"dismissed"');
|
||||
export type SkillReportStatus = (typeof SkillReportStatusSchema)[inferred];
|
||||
export const SkillReportFinalActionSchema = type('"none"|"hide"');
|
||||
|
||||
Reference in New Issue
Block a user