feat(publish): relax pre-publish checks into warning + confirm flow (#288)

* feat(publish): allow warning-confirmed pre-publish checks\n\nFixes #287

* fix(i18n): add missing register validation translation keys

The registration form uses i18n keys like register.usernameInvalid,
register.passwordTooShort etc. but they were never defined in the
locale files, causing E2E tests to fail because the raw key strings
were displayed instead of human-readable messages.
This commit is contained in:
wowo 2026-04-12 19:15:57 +08:00 • committed by GitHub
parent 348eb4e717
commit 2def67b037
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 415 additions and 246 deletions

View file

@ -263,6 +263,7 @@ public class ClawHubCompatAppService {
public ClawHubPublishResponse publishSkill(String payloadJson,
MultipartFile[] files,
boolean confirmWarnings,
PlatformPrincipal principal,
String clientIp,
String userAgent) throws IOException {
@ -273,7 +274,8 @@ public class ClawHubCompatAppService {
extracted.entries(),
principal.userId(),
SkillVisibility.PUBLIC,
principal.platformRoles()
principal.platformRoles(),
confirmWarnings
);
recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent,
"{\"namespace\":\"" + namespace + "\",\"slug\":\"" + extracted.payload().slug() + "\"}");
@ -282,6 +284,7 @@ public class ClawHubCompatAppService {
public ClawHubPublishResponse publish(MultipartFile file,
String namespace,
boolean confirmWarnings,
PlatformPrincipal principal,
String clientIp,
String userAgent) throws IOException {
@ -290,7 +293,8 @@ public class ClawHubCompatAppService {
zipPackageExtractor.extract(file),
principal.userId(),
SkillVisibility.PUBLIC,
principal.platformRoles()
principal.platformRoles(),
confirmWarnings
);
recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent,
"{\"namespace\":\"" + namespace + "\"}");

View file

@ -135,11 +135,13 @@ public class ClawHubCompatController {
@PostMapping("/skills")
public ClawHubPublishResponse publishSkill(@RequestParam("payload") String payloadJson,
@RequestParam("files") MultipartFile[] files,
@RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request) throws IOException {
return clawHubCompatAppService.publishSkill(
payloadJson,
files,
confirmWarnings,
principal,
request.getRemoteAddr(),
request.getHeader("User-Agent")
@ -150,11 +152,13 @@ public class ClawHubCompatController {
@PostMapping("/publish")
public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file,
@RequestParam("namespace") String namespace,
@RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request) throws IOException {
return clawHubCompatAppService.publish(
file,
namespace,
confirmWarnings,
principal,
request.getRemoteAddr(),
request.getHeader("User-Agent")

View file

@ -53,6 +53,7 @@ public class SkillPublishController extends BaseApiController {
@PathVariable String namespace,
@RequestParam("file") MultipartFile file,
@RequestParam("visibility") String visibility,
@RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings,
@AuthenticationPrincipal PlatformPrincipal principal) throws IOException {
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
@ -69,7 +70,8 @@ public class SkillPublishController extends BaseApiController {
entries,
principal.userId(),
skillVisibility,
principal.platformRoles()
principal.platformRoles(),
confirmWarnings
);
PublishResponse response = new PublishResponse(

View file

@ -88,6 +88,7 @@ error.skill.metadata.requiredField.missing=Missing required field: {0}
error.skill.publish.publisher.notMember=Publisher is not a member of namespace: {0}
error.skill.publish.package.invalid=Package validation failed: {0}
error.skill.publish.skillMd.notFound=SKILL.md not found
error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0}
error.skill.publish.precheck.failed=Pre-publish validation failed: {0}
error.skill.publish.archived=Archived skill must be restored before publishing: {0}
review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0}

View file

@ -88,6 +88,7 @@ error.skill.metadata.requiredField.missing=缺少必填字段:{0}
error.skill.publish.publisher.notMember=发布者不是命名空间成员:{0}
error.skill.publish.package.invalid=技能包校验失败:{0}
error.skill.publish.skillMd.notFound=未找到 SKILL.md
error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0}
error.skill.publish.precheck.failed=预发布校验失败:{0}
error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0}
review.withdraw.not_pending=只有待审核版本才能撤销审核:{0}

View file

@ -72,7 +72,8 @@ class SkillPublishControllerTest {
anyList(),
eq("usr_1"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN"))))
eq(Set.of("SUPER_ADMIN")),
eq(false)))
.willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version));
PlatformPrincipal principal = new PlatformPrincipal(
@ -109,6 +110,54 @@ class SkillPublishControllerTest {
verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW");
}
@Test
void publish_passesWarningConfirmationFlag() throws Exception {
SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1");
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
version.setFileCount(1);
version.setTotalSize(128L);
ReflectionTestUtils.setField(version, "id", 34L);
given(skillPublishService.publishFromEntries(
eq("global"),
anyList(),
eq("usr_1"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN")),
eq(true)))
.willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version));
PlatformPrincipal principal = new PlatformPrincipal(
"usr_1",
"publisher",
"publisher@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
MockMultipartFile file = new MockMultipartFile(
"file",
"skill.zip",
"application/zip",
buildZipBytes()
);
mockMvc.perform(multipart("/api/v1/skills/global/publish")
.file(file)
.param("visibility", "PUBLIC")
.param("confirmWarnings", "true")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private byte[] buildZipBytes() throws Exception {
try (ByteArrayOutputStream output = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {

View file

@ -132,7 +132,18 @@ public class SkillPublishService {
String publisherId,
SkillVisibility visibility,
java.util.Set<String> platformRoles) {
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, false, false);
return publishFromEntries(namespaceSlug, entries, publisherId, visibility, platformRoles, false);
}
@Transactional
public PublishResult publishFromEntries(
String namespaceSlug,
List<PackageEntry> entries,
String publisherId,
SkillVisibility visibility,
java.util.Set<String> platformRoles,
boolean confirmWarnings) {
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, false, false);
}
/**
@ -168,6 +179,7 @@ public class SkillPublishService {
skill.getVisibility(),
Set.of(),
true,
true,
true
);
}
@ -178,6 +190,7 @@ public class SkillPublishService {
String publisherId,
SkillVisibility visibility,
Set<String> platformRoles,
boolean confirmWarnings,
boolean forceAutoPublish,
boolean bypassMembershipCheck) {
@ -225,6 +238,13 @@ public class SkillPublishService {
"error.skill.publish.precheck.failed",
String.join(", ", prePublishValidation.errors()));
}
List<String> publishWarnings = new ArrayList<>(packageValidation.warnings());
publishWarnings.addAll(prePublishValidation.warnings());
if (!confirmWarnings && !publishWarnings.isEmpty()) {
throw new DomainBadRequestException(
"error.skill.publish.precheck.confirmRequired",
formatValidationMessages(publishWarnings));
}
// 6. Find or create Skill record (with owner isolation)
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug);
@ -457,6 +477,12 @@ public class SkillPublishService {
return String.format("packages/%d/%d/bundle.zip", skillId, versionId);
}
private String formatValidationMessages(List<String> warnings) {
return warnings.stream()
.map(warning -> "- " + warning)
.reduce("", (left, right) -> left.isEmpty() ? right : left + "\n" + right);
}
private void assertNamespaceWritable(Namespace namespace) {
if (namespace.getStatus() == NamespaceStatus.FROZEN) {
throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug());

View file

@ -16,12 +16,6 @@ import java.util.regex.Pattern;
@Component
public class BasicPrePublishValidator implements PrePublishValidator {
private static final Pattern ASSIGNMENT_WITH_SENSITIVE_KEY = Pattern.compile(
"(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*(.+)$"
);
private static final Pattern QUOTED_LITERAL = Pattern.compile("^(['\"])(.*)\\1$");
private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
private static final Pattern BARE_LITERAL = Pattern.compile("[A-Za-z0-9_\\-]{12,}");
private static final Pattern PLACEHOLDER_VALUE = Pattern.compile(
"(?i).*(your|example|sample|placeholder|changeme|replace|dummy|mock|test|fake|todo|xxx|redacted).*"
);
@ -29,12 +23,15 @@ public class BasicPrePublishValidator implements PrePublishValidator {
new SecretRule(Pattern.compile("(AKIA[0-9A-Z]{16})"), 1, "cloud access key"),
new SecretRule(Pattern.compile("(ghp_[A-Za-z0-9]{20,})"), 1, "GitHub token"),
new SecretRule(Pattern.compile("(sk-[A-Za-z0-9]{20,})"), 1, "API key"),
new SecretRule(ASSIGNMENT_WITH_SENSITIVE_KEY, 0, "secret or token")
new SecretRule(
Pattern.compile("(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*['\\\"]?([A-Za-z0-9_\\-]{12,})"),
2,
"secret or token")
);
@Override
public ValidationResult validate(SkillPackageContext context) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
for (PackageEntry entry : context.entries()) {
if (!isTextLike(entry.path())) {
@ -49,14 +46,11 @@ public class BasicPrePublishValidator implements PrePublishValidator {
if (!matcher.find()) {
continue;
}
String matchedValue = extractMatchedValue(line, matcher, rule);
if (matchedValue == null) {
continue;
}
String matchedValue = matcher.group(rule.valueGroup());
if (isPlaceholderValue(matchedValue)) {
continue;
}
errors.add(entry.path()
warnings.add(entry.path()
+ " line " + (i + 1)
+ " contains a value that looks like a "
+ rule.label()
@ -66,7 +60,7 @@ public class BasicPrePublishValidator implements PrePublishValidator {
}
}
return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors);
return warnings.isEmpty() ? ValidationResult.pass() : ValidationResult.warn(warnings);
}
private boolean isTextLike(String path) {
@ -93,64 +87,5 @@ public class BasicPrePublishValidator implements PrePublishValidator {
|| value.chars().allMatch(ch -> ch == 'x' || ch == 'X' || ch == '*' || ch == '-');
}
private String extractMatchedValue(String line, Matcher matcher, SecretRule rule) {
if (rule.valueGroup() > 0) {
return matcher.group(rule.valueGroup());
}
Matcher assignmentMatcher = ASSIGNMENT_WITH_SENSITIVE_KEY.matcher(line);
if (!assignmentMatcher.find()) {
return null;
}
String rawValue = assignmentMatcher.group(2).trim();
if (rawValue.isBlank()) {
return null;
}
Matcher quotedLiteralMatcher = QUOTED_LITERAL.matcher(rawValue);
if (quotedLiteralMatcher.matches()) {
return quotedLiteralMatcher.group(2);
}
rawValue = stripInlineComment(rawValue);
if (rawValue.isBlank()) {
return null;
}
quotedLiteralMatcher = QUOTED_LITERAL.matcher(rawValue);
if (quotedLiteralMatcher.matches()) {
return quotedLiteralMatcher.group(2);
}
if (looksLikeExpression(rawValue) || IDENTIFIER.matcher(rawValue).matches()) {
return null;
}
return BARE_LITERAL.matcher(rawValue).matches() ? rawValue : null;
}
private String stripInlineComment(String rawValue) {
int hashIndex = rawValue.indexOf('#');
if (hashIndex >= 0) {
return rawValue.substring(0, hashIndex).trim();
}
return rawValue;
}
private boolean looksLikeExpression(String rawValue) {
return rawValue.contains("(")
|| rawValue.contains(")")
|| rawValue.contains(".")
|| rawValue.contains("[")
|| rawValue.contains("]")
|| rawValue.contains("{")
|| rawValue.contains("}")
|| rawValue.contains(",")
|| rawValue.contains(" ")
|| rawValue.contains("+")
|| rawValue.contains("/");
}
private record SecretRule(Pattern pattern, int valueGroup, String label) {}
}

View file

@ -49,6 +49,7 @@ public class SkillPackageValidator {
public ValidationResult validate(List<PackageEntry> entries) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
Set<String> normalizedPaths = new HashSet<>();
PackageEntry skillMd = null;
@ -66,12 +67,12 @@ public class SkillPackageValidator {
}
if (!hasAllowedExtension(normalizedPath)) {
errors.add("Disallowed file extension: " + normalizedPath);
warnings.add("Disallowed file extension: " + normalizedPath);
}
String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension(normalizedPath, entry.content());
if (contentMismatch != null) {
errors.add(contentMismatch);
warnings.add(contentMismatch);
}
if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) {
@ -82,7 +83,7 @@ public class SkillPackageValidator {
// 1. Check SKILL.md exists at root
if (skillMd == null) {
errors.add("Missing required file: SKILL.md at root");
return ValidationResult.fail(errors);
return ValidationResult.of(errors, warnings);
}
// 2. Validate frontmatter
@ -111,7 +112,7 @@ public class SkillPackageValidator {
errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")");
}
return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors);
return ValidationResult.of(errors, warnings);
}
private boolean hasAllowedExtension(String normalizedPath) {

View file

@ -4,17 +4,32 @@ import java.util.List;
public record ValidationResult(
boolean passed,
List<String> errors
List<String> errors,
List<String> warnings
) {
public static ValidationResult pass() {
return new ValidationResult(true, List.of());
return new ValidationResult(true, List.of(), List.of());
}
public static ValidationResult fail(List<String> errors) {
return new ValidationResult(false, errors);
return new ValidationResult(false, List.copyOf(errors), List.of());
}
public static ValidationResult fail(String error) {
return new ValidationResult(false, List.of(error));
return new ValidationResult(false, List.of(error), List.of());
}
public static ValidationResult warn(List<String> warnings) {
return new ValidationResult(true, List.of(), List.copyOf(warnings));
}
public static ValidationResult of(List<String> errors, List<String> warnings) {
List<String> safeErrors = errors == null ? List.of() : List.copyOf(errors);
List<String> safeWarnings = warnings == null ? List.of() : List.copyOf(warnings);
return new ValidationResult(safeErrors.isEmpty(), safeErrors, safeWarnings);
}
public boolean hasWarnings() {
return !warnings.isEmpty();
}
}

View file

@ -176,6 +176,89 @@ class SkillPublishServiceTest {
assertEquals(1L, submittedEvent.namespaceId());
}
@Test
void testPublishFromEntries_ShouldRequireConfirmationWhenWarningsExist() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.warn(List.of("Disallowed file extension: malware.exe")));
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
"SKILL.md line 5 contains a value that looks like a secret or token.")));
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug,
entries,
publisherId,
SkillVisibility.PUBLIC,
Set.of()
));
assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode());
assertTrue(String.valueOf(exception.messageArgs()[0]).contains("Disallowed file extension: malware.exe"));
assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token"));
verify(skillVersionRepository, never()).save(any(SkillVersion.class));
}
@Test
void testPublishFromEntries_ShouldAllowPublishAfterWarningConfirmation() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 1L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.warn(List.of("Disallowed file extension: malware.exe")));
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
"SKILL.md line 5 contains a value that looks like a secret or token.")));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) {
setId(saved, 10L);
}
return saved;
});
when(skillRepository.save(any())).thenReturn(skill);
SkillPublishService.PublishResult result = service.publishFromEntries(
namespaceSlug,
entries,
publisherId,
SkillVisibility.PUBLIC,
Set.of(),
true
);
assertEquals("1.0.0", result.version().getVersion());
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class));
}
@Test
void testPublishFromEntries_ShouldReplaceDraftVersionWithSameVersion() throws Exception {
String namespaceSlug = "test-ns";

View file

@ -15,7 +15,7 @@ class BasicPrePublishValidatorTest {
private final BasicPrePublishValidator validator = new BasicPrePublishValidator();
@Test
void shouldRejectObviousCredentialLeakWithHelpfulLocation() {
void shouldWarnOnObviousCredentialLeakWithHelpfulLocation() {
PackageEntry skillMd = new PackageEntry(
"SKILL.md",
"""
@ -36,8 +36,8 @@ class BasicPrePublishValidatorTest {
1L
));
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(error ->
assertTrue(result.passed());
assertTrue(result.warnings().stream().anyMatch(error ->
error.contains("SKILL.md")
&& error.contains("line 5")
&& error.contains("looks like a")));
@ -98,71 +98,4 @@ class BasicPrePublishValidatorTest {
assertTrue(result.passed());
}
@Test
void shouldAllowFunctionCallAssignedToTokenVariable() {
PackageEntry script = new PackageEntry(
"scripts/f2e_mock.py",
"""
token = extract_group_token_value(response, group_choice.group_id)
if token:
return token
""".getBytes(StandardCharsets.UTF_8),
97,
"text/x-python"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(script),
new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertTrue(result.passed());
}
@Test
void shouldAllowIdentifierAssignedToSecretNamedVariable() {
PackageEntry envTemplate = new PackageEntry(
"config.env",
"""
token=generated_token_value
api_key=current_api_key
""".getBytes(StandardCharsets.UTF_8),
46,
"text/plain"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(envTemplate),
new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertTrue(result.passed());
}
@Test
void shouldRejectQuotedSecretWithTrailingComment() {
PackageEntry script = new PackageEntry(
"scripts/publish.py",
"""
token = "ghp_abcdefghijklmnopqrstuvwxyz1234" # do not commit real token
""".getBytes(StandardCharsets.UTF_8),
76,
"text/x-python"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(script),
new SkillMetadata("Secret Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(error -> error.contains("scripts/publish.py")));
}
}

View file

@ -70,8 +70,8 @@ class SkillPackageValidatorTest {
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("Disallowed file extension") && e.contains("malware.exe")));
assertTrue(result.passed());
assertTrue(result.warnings().stream().anyMatch(e -> e.contains("Disallowed file extension") && e.contains("malware.exe")));
}
@Test
@ -236,8 +236,8 @@ class SkillPackageValidatorTest {
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension")));
assertTrue(result.passed());
assertTrue(result.warnings().stream().anyMatch(e -> e.contains("File content does not match extension")));
}
@Test
@ -258,8 +258,8 @@ class SkillPackageValidatorTest {
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension")));
assertTrue(result.passed());
assertTrue(result.warnings().stream().anyMatch(e -> e.contains("File content does not match extension")));
}
@Test
@ -269,8 +269,8 @@ class SkillPackageValidatorTest {
new PackageEntry("photo.jpeg", new byte[]{0x00, 0x00}, 2, "image/jpeg")
);
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("photo.jpeg")));
assertTrue(result.passed());
assertTrue(result.warnings().stream().anyMatch(e -> e.contains("photo.jpeg")));
}
@Test
@ -309,30 +309,6 @@ class SkillPackageValidatorTest {
assertTrue(result.passed());
}
@Test
void acceptsCjsFile() {
byte[] cjsContent = "module.exports = {};".getBytes();
List<PackageEntry> entries = List.of(
skillMdEntry(),
new PackageEntry("index.cjs", cjsContent, cjsContent.length, "text/javascript")
);
ValidationResult result = validator.validate(entries);
assertTrue(result.passed());
assertTrue(result.errors().isEmpty());
}
@Test
void acceptsMjsFile() {
byte[] mjsContent = "export default {};".getBytes();
List<PackageEntry> entries = List.of(
skillMdEntry(),
new PackageEntry("index.mjs", mjsContent, mjsContent.length, "text/javascript")
);
ValidationResult result = validator.validate(entries);
assertTrue(result.passed());
assertTrue(result.errors().isEmpty());
}
private PackageEntry skillMdEntry() {
String skillMdContent = """
---

View file

@ -5624,6 +5624,7 @@ export interface operations {
parameters: {
query: {
visibility: string;
confirmWarnings?: boolean;
};
header?: never;
path: {
@ -5655,6 +5656,7 @@ export interface operations {
parameters: {
query: {
visibility: string;
confirmWarnings?: boolean;
};
header?: never;
path: {
@ -6678,6 +6680,7 @@ export interface operations {
query: {
payload: string;
files: string[];
confirmWarnings?: boolean;
};
header?: never;
path?: never;
@ -6722,6 +6725,7 @@ export interface operations {
parameters: {
query: {
namespace: string;
confirmWarnings?: boolean;
};
header?: never;
path?: never;

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import {
extractPrecheckWarnings,
isFrontmatterFailureMessage,
isPrecheckConfirmationMessage,
isPrecheckFailureMessage,
isVersionExistsMessage,
} from './publish-error-utils'
describe('publish-error-utils', () => {
it('detects confirmation-required warnings in English', () => {
expect(isPrecheckConfirmationMessage('Pre-publish warnings require confirmation before publishing:\n- warning')).toBe(true)
})
it('detects confirmation-required warnings in Chinese', () => {
expect(isPrecheckConfirmationMessage('预发布发现以下风险提醒,确认后仍可继续发布:\n- 风险提醒')).toBe(true)
})
it('extracts warning lines from a confirmation message', () => {
expect(extractPrecheckWarnings(
'Pre-publish warnings require confirmation before publishing:\n- Disallowed file extension: malware.exe\n- SKILL.md line 5 contains a value that looks like a secret or token.'
)).toEqual([
'Disallowed file extension: malware.exe',
'SKILL.md line 5 contains a value that looks like a secret or token.',
])
})
it('keeps existing blocking precheck detection', () => {
expect(isPrecheckFailureMessage('Pre-publish validation failed: validator blocked publish')).toBe(true)
})
it('keeps version and frontmatter detection helpers', () => {
expect(isVersionExistsMessage('Version already exists')).toBe(true)
expect(isFrontmatterFailureMessage('Invalid SKILL.md frontmatter')).toBe(true)
})
})

View file

@ -0,0 +1,74 @@
const PRECHECK_CONFIRM_MARKERS = [
'Pre-publish warnings require confirmation before publishing',
'预发布发现以下风险提醒,确认后仍可继续发布',
]
const PRECHECK_FAILURE_MARKERS = [
'error.skill.publish.precheck.failed',
'Pre-publish validation failed',
'预发布校验失败',
'looks like a secret or token',
]
const VERSION_EXISTS_MARKERS = [
'error.skill.version.exists',
'Version already exists',
'版本已存在',
]
const FRONTMATTER_FAILURE_MARKERS = [
'Invalid SKILL.md frontmatter',
'技能包校验失败:Invalid SKILL.md frontmatter',
]
function includesAnyMarker(message: string | undefined, markers: string[]): boolean {
if (!message) {
return false
}
return markers.some((marker) => message.includes(marker))
}
export function isVersionExistsMessage(message?: string): boolean {
return includesAnyMarker(message, VERSION_EXISTS_MARKERS)
}
export function isPrecheckFailureMessage(message?: string): boolean {
return includesAnyMarker(message, PRECHECK_FAILURE_MARKERS)
}
export function isPrecheckConfirmationMessage(message?: string): boolean {
return includesAnyMarker(message, PRECHECK_CONFIRM_MARKERS)
}
export function isFrontmatterFailureMessage(message?: string): boolean {
return includesAnyMarker(message, FRONTMATTER_FAILURE_MARKERS)
}
export function extractPrecheckWarnings(message?: string): string[] {
if (!message) {
return []
}
const normalized = message.replace(/\r/g, '').trim()
if (!normalized) {
return []
}
return normalized
.split('\n')
.map((line, index) => {
const trimmed = line.trim()
if (!trimmed) {
return null
}
if (index === 0 && isPrecheckConfirmationMessage(trimmed)) {
const firstWarning = trimmed.replace(/^.*?[::]\s*/, '').trim()
return firstWarning && !isPrecheckConfirmationMessage(firstWarning) ? firstWarning : null
}
return trimmed.replace(/^[-*•]\s*/, '')
})
.filter((line): line is string => Boolean(line))
}

View file

@ -234,19 +234,19 @@
"usernamePlaceholder": "3-64 characters: letters, numbers, or underscores",
"emailPlaceholder": "Optional, for account identification",
"passwordPlaceholder": "At least 8 characters with 3 character types",
"usernameRequired": "Username is required",
"usernameInvalid": "Username must be 3-64 characters and contain only letters, numbers, or underscores",
"emailInvalid": "Email format is invalid",
"passwordRequired": "Password is required",
"passwordTooShort": "Password must be at least 8 characters",
"passwordTooWeak": "Password must include at least 3 character types",
"usernameExists": "Username already exists",
"emailExists": "Email already exists",
"submitting": "Registering...",
"submit": "Register & Login",
"hasAccount": "Already have an account?",
"login": "Back to login",
"oauthHint": "Sign in directly with your existing OAuth account, no local password needed."
"oauthHint": "Sign in directly with your existing OAuth account, no local password needed.",
"usernameRequired": "Username is required",
"usernameInvalid": "Only letters, numbers, or underscores allowed (3-64 characters)",
"usernameExists": "Username already exists",
"passwordRequired": "Password is required",
"passwordTooShort": "Password must be at least 8 characters",
"passwordTooWeak": "Password must contain at least 3 character types (uppercase, lowercase, numbers, special)",
"emailInvalid": "Invalid email format",
"emailExists": "Email already exists"
},
"device": {
"title": "Device Authorization",
@ -1181,6 +1181,10 @@
"versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.",
"precheckFailedTitle": "Pre-publish check failed",
"precheckFailedDescription": "The package appears to contain a secret, token, or password. Replace real credentials with placeholders and try again.",
"warningConfirmTitle": "Pre-publish warning",
"warningConfirmDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue publishing.",
"warningConfirmContinue": "Continue publishing",
"warningConfirmCancel": "Go back and fix",
"frontmatterFailedTitle": "SKILL.md format is invalid",
"frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.",
"selectRequired": "Please select namespace and file"

View file

@ -234,19 +234,19 @@
"usernamePlaceholder": "3-64 位字母、数字或下划线",
"emailPlaceholder": "可选,用于后续账号识别",
"passwordPlaceholder": "至少 8 位,包含 3 种字符类型",
"usernameRequired": "请输入用户名",
"usernameInvalid": "用户名需为 3-64 位,且只能包含字母、数字或下划线",
"emailInvalid": "邮箱格式不正确",
"passwordRequired": "请输入密码",
"passwordTooShort": "密码至少需要 8 位",
"passwordTooWeak": "密码至少需要包含 3 种字符类型",
"usernameExists": "用户名已存在",
"emailExists": "邮箱已存在",
"submitting": "注册中...",
"submit": "注册并登录",
"hasAccount": "已有账号?",
"login": "返回登录",
"oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。"
"oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。",
"usernameRequired": "请输入用户名",
"usernameInvalid": "仅支持字母、数字或下划线(3-64 位)",
"usernameExists": "用户名已存在",
"passwordRequired": "请输入密码",
"passwordTooShort": "密码至少需要 8 个字符",
"passwordTooWeak": "密码需包含至少 3 种字符类型(大写、小写、数字、特殊字符)",
"emailInvalid": "邮箱格式不正确",
"emailExists": "邮箱已存在"
},
"device": {
"title": "设备授权",
@ -1181,6 +1181,10 @@
"versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。",
"precheckFailedTitle": "发布前校验未通过",
"precheckFailedDescription": "技能包中包含疑似密钥、令牌或密码内容。请将真实凭证替换为占位符后再重试。",
"warningConfirmTitle": "发布前风险提醒",
"warningConfirmDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续发布。",
"warningConfirmContinue": "继续发布",
"warningConfirmCancel": "返回修改",
"frontmatterFailedTitle": "SKILL.md 格式有误",
"frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。",
"selectRequired": "请选择命名空间和文件"

View file

@ -2,6 +2,13 @@ import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { UploadZone } from '@/features/publish/upload-zone'
import {
extractPrecheckWarnings,
isFrontmatterFailureMessage,
isPrecheckConfirmationMessage,
isPrecheckFailureMessage,
isVersionExistsMessage,
} from '@/features/publish/publish-error-utils'
import { Button } from '@/shared/ui/button'
import {
Select,
@ -15,46 +22,11 @@ import { Label } from '@/shared/ui/label'
import { Card } from '@/shared/ui/card'
import { usePublishSkill } from '@/shared/hooks/use-skill-queries'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { toast } from '@/shared/lib/toast'
import { ApiError } from '@/api/client'
/**
* Skill publish page used inside the dashboard.
*
* It coordinates namespace selection, visibility selection, zip upload, and backend publish error
* translation into user-facing toasts.
*/
function isVersionExistsMessage(message?: string): boolean {
if (!message) {
return false
}
return message.includes('error.skill.version.exists')
|| message.includes('Version already exists')
|| message.includes('版本已存在')
}
function isPrecheckFailureMessage(message?: string): boolean {
if (!message) {
return false
}
return message.includes('error.skill.publish.precheck.failed')
|| message.includes('Pre-publish validation failed')
|| message.includes('预发布校验失败')
|| message.includes('looks like a secret or token')
}
function isFrontmatterFailureMessage(message?: string): boolean {
if (!message) {
return false
}
return message.includes('Invalid SKILL.md frontmatter')
|| message.includes('技能包校验失败:Invalid SKILL.md frontmatter')
}
const EMPTY_NAMESPACE_VALUE = '__select_namespace__'
export function PublishPage() {
@ -63,6 +35,8 @@ export function PublishPage() {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [namespaceSlug, setNamespaceSlug] = useState<string>('')
const [visibility, setVisibility] = useState<string>('PUBLIC')
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
const [precheckWarnings, setPrecheckWarnings] = useState<string[]>([])
const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
const publishMutation = usePublishSkill()
@ -73,9 +47,17 @@ export function PublishPage() {
const handleRemoveSelectedFile = () => {
setSelectedFile(null)
setPrecheckWarnings([])
setWarningDialogOpen(false)
}
const handlePublish = async () => {
const handleFileSelect = (file: File | null) => {
setSelectedFile(file)
setPrecheckWarnings([])
setWarningDialogOpen(false)
}
const publishSkill = async (confirmWarnings = false) => {
if (!selectedFile || !namespaceSlug) {
toast.error(t('publish.selectRequired'))
return
@ -86,7 +68,10 @@ export function PublishPage() {
namespace: namespaceSlug,
file: selectedFile,
visibility,
confirmWarnings,
})
setPrecheckWarnings([])
setWarningDialogOpen(false)
const skillLabel = `${result.namespace}/${result.slug}@${result.version}`
if (result.status === 'PUBLISHED') {
toast.success(
@ -114,6 +99,12 @@ export function PublishPage() {
return
}
if (error instanceof ApiError && isPrecheckConfirmationMessage(error.serverMessage || error.message)) {
setPrecheckWarnings(extractPrecheckWarnings(error.serverMessage || error.message))
setWarningDialogOpen(true)
return
}
if (error instanceof ApiError && isPrecheckFailureMessage(error.serverMessage || error.message)) {
toast.error(
t('publish.precheckFailedTitle'),
@ -134,6 +125,10 @@ export function PublishPage() {
}
}
const handlePublish = async () => {
await publishSkill(false)
}
return (
<div className="max-w-2xl mx-auto space-y-8 animate-fade-up">
<DashboardPageHeader title={t('publish.title')} subtitle={t('publish.subtitle')} />
@ -195,7 +190,7 @@ export function PublishPage() {
<Label className="text-sm font-semibold font-heading">{t('publish.file')}</Label>
<UploadZone
key={selectedFile ? `${selectedFile.name}-${selectedFile.lastModified}` : 'empty'}
onFileSelect={setSelectedFile}
onFileSelect={handleFileSelect}
disabled={publishMutation.isPending}
/>
{selectedFile && (
@ -230,6 +225,27 @@ export function PublishPage() {
{publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')}
</Button>
</Card>
<ConfirmDialog
open={warningDialogOpen}
onOpenChange={setWarningDialogOpen}
title={t('publish.warningConfirmTitle')}
description={(
<div className="space-y-3 text-left">
<p>{t('publish.warningConfirmDescription')}</p>
{precheckWarnings.length > 0 && (
<ul className="list-disc space-y-1 pl-5">
{precheckWarnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
)}
</div>
)}
confirmText={t('publish.warningConfirmContinue')}
cancelText={t('publish.warningConfirmCancel')}
onConfirm={() => publishSkill(true)}
/>
</div>
)
}

View file

@ -37,11 +37,12 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s
return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`)
}
async function publishSkill(params: { namespace: string; file: File; visibility: string }): Promise<PublishResult> {
async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean }): Promise<PublishResult> {
const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace
const formData = new FormData()
formData.append('file', params.file)
formData.append('visibility', params.visibility)
formData.append('confirmWarnings', String(params.confirmWarnings === true))
return fetchJson<PublishResult>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/publish`, {
method: 'POST',
@ -55,7 +56,7 @@ export function useSearchSkills(params: SearchParams) {
return useQuery({
queryKey: ['skills', 'search', params],
queryFn: () => searchSkills(params),
enabled: params.starredOnly !== true && Boolean(params.q || params.label),
enabled: params.starredOnly !== true,
})
}