mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
feat: add user-invoked bounded JSON artifact checks
This commit is contained in:
parent
6a00c3869d
commit
fb7bb04f7a
@ -3,6 +3,10 @@ package vip.mate.execution.evidence.controller;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import java.util.List;
|
||||
import vip.mate.execution.evidence.service.JsonArtifactRecipe;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@ -36,4 +40,14 @@ public class ExecutionEvidenceController {
|
||||
@PathVariable Long id) {
|
||||
return R.ok(queries.detail(auth == null ? null : auth.getName(), workspaceId, id));
|
||||
}
|
||||
public record JsonCheckRequest(List<String> requiredFields) { }
|
||||
|
||||
@PostMapping("/{id}/json-check")
|
||||
public R<JsonArtifactRecipe.Result> checkJson(Authentication auth,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
@PathVariable Long id, @RequestBody JsonCheckRequest request) {
|
||||
return R.ok(queries.checkJson(auth == null ? null : auth.getName(), workspaceId, id,
|
||||
request == null ? null : request.requiredFields()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -77,11 +77,30 @@ public class ExecutionEvidenceQueryService {
|
||||
}
|
||||
|
||||
public View detail(String username, Long workspaceId, Long id) {
|
||||
return authorizedDetail(username, workspaceId, id, true);
|
||||
}
|
||||
|
||||
private View authorizedDetail(String username, Long workspaceId, Long id, boolean inspectVersion) {
|
||||
if (username == null || username.isBlank() || id == null) throw hidden();
|
||||
ExecutionEvidence row = store.findById(id).orElseThrow(this::hidden);
|
||||
Long canonicalWorkspace = authorize(username, workspaceId, row.conversationId());
|
||||
if (!canonicalWorkspace.equals(row.workspaceId())) throw hidden();
|
||||
return view(username, row, store.findAttempt(row.attemptId()).orElseThrow(this::hidden), true);
|
||||
return view(username, row, store.findAttempt(row.attemptId()).orElseThrow(this::hidden), inspectVersion);
|
||||
}
|
||||
|
||||
public JsonArtifactRecipe.Result checkJson(String username, Long workspaceId, Long id, List<String> fields) {
|
||||
// Reuse source, attempt and file authorization before any content read.
|
||||
View view = authorizedDetail(username, workspaceId, id, false);
|
||||
List<String> required = JsonArtifactRecipe.validate(fields);
|
||||
if (view.kind() != EvidenceKind.ARTIFACT_SNAPSHOT || view.artifactRef() == null
|
||||
|| "UNAVAILABLE".equals(view.validity())) {
|
||||
return JsonArtifactRecipe.outcome("UNAVAILABLE", required, List.of());
|
||||
}
|
||||
Long ownerWorkspace = authorize(username, workspaceId, view.conversationId());
|
||||
var read = files.readDurableArtifactSnapshot(view.artifactRef(), ownerWorkspace, view.conversationId(),
|
||||
view.artifactDigest(), properties.getArtifactVersionCheckMaxBytes());
|
||||
return "READ".equals(read.status()) ? JsonArtifactRecipe.check(read.bytes(), required)
|
||||
: JsonArtifactRecipe.outcome(read.status(), required, List.of());
|
||||
}
|
||||
|
||||
private Long authorize(String username, Long workspaceId, String conversationId) {
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
package vip.mate.execution.evidence.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonFactory;
|
||||
import com.fasterxml.jackson.core.StreamReadConstraints;
|
||||
import com.fasterxml.jackson.core.StreamReadFeature;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** Explicit, read-only check of captured bytes; never an acceptance binding. */
|
||||
public final class JsonArtifactRecipe {
|
||||
private static final ObjectMapper JSON = new ObjectMapper(JsonFactory.builder()
|
||||
.streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(32)
|
||||
.maxStringLength(1_048_576).maxNameLength(1024).build())
|
||||
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build())
|
||||
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
|
||||
|
||||
private JsonArtifactRecipe() { }
|
||||
|
||||
public record Result(String recipeId, int recipeRevision, String status, List<String> requiredFields,
|
||||
List<String> missingFields, Instant checkedAt, boolean acceptanceEligible) { }
|
||||
|
||||
public static List<String> validate(List<String> fields) {
|
||||
if (fields == null || fields.isEmpty() || fields.size() > 16
|
||||
|| fields.stream().anyMatch(f -> f == null || f.isBlank() || f.length() > 128
|
||||
|| f.chars().anyMatch(Character::isISOControl))
|
||||
|| new HashSet<>(fields).size() != fields.size()) {
|
||||
throw new MateClawException(400, "Specify 1–16 unique top-level JSON fields, each 1–128 characters");
|
||||
}
|
||||
return List.copyOf(fields);
|
||||
}
|
||||
|
||||
public static Result outcome(String status, List<String> fields, List<String> missing) {
|
||||
return new Result("json-required-fields", 1, status, List.copyOf(fields), List.copyOf(missing),
|
||||
Instant.now(), false);
|
||||
}
|
||||
|
||||
public static Result check(byte[] bytes, List<String> requestedFields) {
|
||||
List<String> fields = validate(requestedFields);
|
||||
if (bytes == null || bytes.length > 1_048_576) return outcome("UNKNOWN", fields, List.of());
|
||||
try {
|
||||
var document = JSON.readTree(bytes);
|
||||
if (document == null || !document.isObject()) return outcome("INVALID_JSON", fields, List.of());
|
||||
List<String> missing = fields.stream().filter(field -> !document.hasNonNull(field)).toList();
|
||||
return outcome(missing.isEmpty() ? "MATCH" : "MISSING_FIELDS", fields, missing);
|
||||
} catch (Exception invalid) {
|
||||
// Parser diagnostics can contain file content; do not return or log them.
|
||||
return outcome("INVALID_JSON", fields, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -449,6 +449,42 @@ public class GeneratedFileCache {
|
||||
}
|
||||
}
|
||||
|
||||
/** One bounded read for an explicit content check, never a managed-scope certificate. */
|
||||
public record ArtifactRead(String status, byte[] bytes) {
|
||||
public ArtifactRead { bytes = bytes == null ? null : bytes.clone(); }
|
||||
@Override public byte[] bytes() { return bytes == null ? null : bytes.clone(); }
|
||||
}
|
||||
|
||||
public ArtifactRead readDurableArtifactSnapshot(String id, Long workspaceId, String conversationId,
|
||||
String expectedDigest, int maxBytes) {
|
||||
try {
|
||||
if (workspaceId == null || conversationId == null) return new ArtifactRead("UNAVAILABLE", null);
|
||||
Metadata before = availableMetadata(id, workspaceId, conversationId);
|
||||
if (before == null) return new ArtifactRead("UNAVAILABLE", null);
|
||||
int budget = Math.clamp(maxBytes, 0, 1_048_576);
|
||||
if (budget == 0 || expectedDigest == null || !expectedDigest.matches("[0-9a-fA-F]{64}")) {
|
||||
return new ArtifactRead("UNKNOWN", null);
|
||||
}
|
||||
Path bin = storageDir.resolve(id);
|
||||
if (Files.size(bin) > budget) return new ArtifactRead("UNKNOWN", null);
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(bin, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
bytes = input.readNBytes(budget + 1);
|
||||
}
|
||||
if (bytes.length > budget) return new ArtifactRead("UNKNOWN", null);
|
||||
Metadata after = availableMetadata(id, workspaceId, conversationId);
|
||||
if (after == null) return new ArtifactRead("UNAVAILABLE", null);
|
||||
if (!before.equals(after)) return new ArtifactRead("UNKNOWN", null);
|
||||
String actual = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
return expectedDigest.equalsIgnoreCase(actual)
|
||||
? new ArtifactRead("READ", bytes) : new ArtifactRead("STALE", null);
|
||||
} catch (IOException | RuntimeException unavailable) {
|
||||
return new ArtifactRead("UNAVAILABLE", null);
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private Metadata availableMetadata(String id, Long workspaceId, String conversationId) throws IOException {
|
||||
if (id == null || !ID_RE.matcher(id).matches()) return null;
|
||||
Path bin = storageDir.resolve(id).normalize();
|
||||
|
||||
@ -163,6 +163,50 @@ class ExecutionEvidenceQueryTest {
|
||||
assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity());
|
||||
}
|
||||
|
||||
@Test void jsonChecksAreSourceAuthorizedAndDoNotInspectUnavailableFiles() {
|
||||
when(store.findById(id)).thenReturn(Optional.of(evidence(id)));
|
||||
assertThrows(MateClawException.class, () -> queries.checkJson("stranger", 1L, id, List.of("report")));
|
||||
assertThrows(MateClawException.class, () -> queries.checkJson("owner", 2L, id, List.of("report")));
|
||||
assertEquals("UNAVAILABLE", queries.checkJson("owner", 1L, id, List.of("report")).status());
|
||||
var artifact = new EvidenceObservation("artifact:private", EvidenceKind.ARTIFACT_SNAPSHOT,
|
||||
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
|
||||
null, "private", "digest", "private metadata", null, now, null);
|
||||
when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", artifact)));
|
||||
assertEquals("UNAVAILABLE", queries.checkJson("owner", 1L, id, List.of("report")).status());
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@Test void jsonCheckUsesRealDurableBytesWithoutPromotingEvidence(@org.junit.jupiter.api.io.TempDir java.nio.file.Path root) throws Exception {
|
||||
var cache = new GeneratedFileCache(root);
|
||||
byte[] bytes = "{\"report\":true}".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
String artifactId = cache.put(bytes, "report.json", "application/json", new GeneratedFileCache.Owner(1L, 1L, "conv"));
|
||||
String digest = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
var observation = new EvidenceObservation("artifact:" + artifactId, EvidenceKind.ARTIFACT_SNAPSHOT,
|
||||
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
|
||||
null, artifactId, digest, "registered file", null, Instant.now(), Instant.now().plusSeconds(3600));
|
||||
when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", observation)));
|
||||
var auth = mock(AuthService.class);
|
||||
var user = new vip.mate.auth.model.UserEntity(); user.setId(1L); user.setRole("user");
|
||||
when(auth.findByUsername("owner")).thenReturn(user);
|
||||
var workspaces = mock(WorkspaceService.class);
|
||||
when(workspaces.hasPermissionCached(1L, 1L, "viewer")).thenReturn(true);
|
||||
var properties = new ExecutionEvidenceProperties();
|
||||
queries = new ExecutionEvidenceQueryService(store, conversations, teams, cache, auth,
|
||||
workspaces, properties, new SimpleMeterRegistry());
|
||||
var result = queries.checkJson("owner", 1L, id, List.of("report"));
|
||||
assertEquals("MATCH", result.status());
|
||||
assertFalse(result.acceptanceEligible());
|
||||
assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity());
|
||||
assertEquals("MISSING_FIELDS", queries.checkJson("owner", 1L, id, List.of("appendix")).status());
|
||||
properties.setArtifactVersionCheckMaxBytes(0);
|
||||
assertEquals("UNKNOWN", queries.checkJson("owner", 1L, id, List.of("report")).status());
|
||||
properties.setArtifactVersionCheckMaxBytes(1024);
|
||||
java.nio.file.Files.writeString(root.resolve(artifactId), "{\"report\":false}");
|
||||
assertEquals("STALE", queries.checkJson("owner", 1L, id, List.of("report")).status());
|
||||
when(workspaces.hasPermissionCached(1L, 1L, "viewer")).thenReturn(false);
|
||||
assertEquals("UNAVAILABLE", queries.checkJson("owner", 1L, id, List.of("report")).status());
|
||||
}
|
||||
|
||||
private ExecutionEvidenceQueryService.Page list(String user, Long workspace, String conversation, String cursor, Integer limit) {
|
||||
return queries.list(user, workspace, conversation, cursor, limit, null, null);
|
||||
}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
package vip.mate.execution.evidence;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.execution.evidence.service.JsonArtifactRecipe;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JsonArtifactRecipeTest {
|
||||
private JsonArtifactRecipe.Result check(String json) {
|
||||
return JsonArtifactRecipe.check(json.getBytes(StandardCharsets.UTF_8), List.of("report"));
|
||||
}
|
||||
@Test void checksOnlyNonNullTopLevelFieldsAndNeverGrantsAcceptance() {
|
||||
var match = check("{\"report\":false,\"secret\":\"not returned\"}");
|
||||
assertEquals("MATCH", match.status());
|
||||
assertFalse(match.acceptanceEligible());
|
||||
assertEquals("json-required-fields", match.recipeId());
|
||||
assertEquals(1, match.recipeRevision());
|
||||
assertNotNull(match.checkedAt());
|
||||
assertFalse(match.toString().contains("not returned"));
|
||||
assertEquals(List.of("report"), check("{\"nested\":{\"report\":1}}").missingFields());
|
||||
assertEquals("MISSING_FIELDS", check("{\"report\":null}").status());
|
||||
}
|
||||
@Test void rejectsAmbiguousMalformedAndExcessiveJson() {
|
||||
for (String text : List.of("", "null", "[]", "oops", "{\"report\":1} {}",
|
||||
"{\"report\":1,\"report\":2}", "{\"report\":" + "[".repeat(40) + "0" + "]".repeat(40) + "}")) {
|
||||
assertEquals("INVALID_JSON", check(text).status(), text);
|
||||
}
|
||||
assertEquals("UNKNOWN", JsonArtifactRecipe.check(new byte[1_048_577], List.of("report")).status());
|
||||
}
|
||||
@Test void rejectsEmptyDuplicateAndOversizedRequirements() {
|
||||
assertThrows(MateClawException.class, () -> JsonArtifactRecipe.validate(null));
|
||||
for (List<String> fields : List.of(List.<String>of(), List.of(""), List.of("x", "x"),
|
||||
List.of("a\nb"), List.of("x".repeat(129)), java.util.stream.IntStream.range(0, 17)
|
||||
.mapToObj(i -> "key" + i).toList())) {
|
||||
assertEquals(400, assertThrows(MateClawException.class, () -> JsonArtifactRecipe.validate(fields)).getCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -65,5 +65,36 @@ class GeneratedFileArtifactVersionTest {
|
||||
Files.createSymbolicLink(root.resolve(id), outside);
|
||||
assertFalse(cache.isDurablyAvailable(id, 1L, "conv"));
|
||||
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
|
||||
assertEquals("UNAVAILABLE", cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 1024).status());
|
||||
}
|
||||
@Test void boundedSnapshotReadsOnlyMatchingOwnedDurableBytes() throws Exception {
|
||||
var cache = new GeneratedFileCache(root);
|
||||
String id = put(cache, "report");
|
||||
var read = cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 6);
|
||||
assertEquals("READ", read.status());
|
||||
assertEquals("report", new String(read.bytes(), StandardCharsets.UTF_8));
|
||||
read.bytes()[0] = 'X';
|
||||
assertEquals("report", new String(read.bytes(), StandardCharsets.UTF_8));
|
||||
assertEquals("UNKNOWN", cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 5).status());
|
||||
assertEquals("UNKNOWN", cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 0).status());
|
||||
assertEquals("UNKNOWN", cache.readDurableArtifactSnapshot(id, 1L, "conv", "fake", 10).status());
|
||||
assertEquals("UNAVAILABLE", cache.readDurableArtifactSnapshot(id, 2L, "conv", digest("report"), 10).status());
|
||||
assertEquals("UNAVAILABLE", cache.readDurableArtifactSnapshot(id, 1L, "other", digest("report"), 10).status());
|
||||
Files.writeString(root.resolve(id), "changed");
|
||||
var stale = cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 10);
|
||||
assertEquals("STALE", stale.status());
|
||||
assertNull(stale.bytes());
|
||||
Files.delete(root.resolve(id));
|
||||
assertEquals("UNAVAILABLE", cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 10).status());
|
||||
}
|
||||
|
||||
@Test void snapshotReadHasAHardOneMebibyteLimit() throws Exception {
|
||||
var cache = new GeneratedFileCache(root);
|
||||
String content = "x".repeat(1_048_577);
|
||||
String id = put(cache, content);
|
||||
var result = cache.readDurableArtifactSnapshot(id, 1L, "conv", digest(content), Integer.MAX_VALUE);
|
||||
assertEquals("UNKNOWN", result.status());
|
||||
assertNull(result.bytes());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -18,4 +18,10 @@ describe('executionEvidenceApi', () => {
|
||||
adapter: async config => ({ data: { code: 403, msg: 'Forbidden', data: null }, status: 200, statusText: 'OK', headers: {}, config }),
|
||||
})).rejects.toMatchObject({ code: 403, message: 'Forbidden' })
|
||||
})
|
||||
it('sends explicit requirements while preserving the evidence ID', () => {
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({} as never)
|
||||
executionEvidenceApi.checkJson('9223372036854775804', ['report', 'appendix'])
|
||||
expect(post).toHaveBeenCalledWith('/execution-evidence/9223372036854775804/json-check', { requiredFields: ['report', 'appendix'] })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -26,7 +26,17 @@ export interface ExecutionEvidenceQuery {
|
||||
cursor?: string
|
||||
limit?: number
|
||||
}
|
||||
export interface ArtifactJsonCheck {
|
||||
recipeId: string
|
||||
recipeRevision: number
|
||||
status: 'MATCH' | 'MISSING_FIELDS' | 'INVALID_JSON' | 'UNKNOWN' | 'STALE' | 'UNAVAILABLE'
|
||||
requiredFields: string[]
|
||||
missingFields: string[]
|
||||
checkedAt: string
|
||||
acceptanceEligible: false
|
||||
}
|
||||
export const executionEvidenceApi = {
|
||||
list: (params: ExecutionEvidenceQuery) => http.get<never, { data: ExecutionEvidencePage }>('/execution-evidence', { params }),
|
||||
checkJson: (id: string, requiredFields: string[]) => http.post<never, { data: ArtifactJsonCheck }>(`/execution-evidence/${encodeURIComponent(id)}/json-check`, { requiredFields }),
|
||||
get: (id: string) => http.get<never, { data: ExecutionEvidence }>(`/execution-evidence/${encodeURIComponent(id)}`),
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { executionEvidenceApi, type ExecutionEvidence } from '@/api/executionEvidence'
|
||||
import { executionEvidenceApi, type ExecutionEvidence, type ArtifactJsonCheck } from '@/api/executionEvidence'
|
||||
|
||||
const props = defineProps<{ conversationId: string; goalId?: string; teamTaskId?: string }>()
|
||||
const { t } = useI18n()
|
||||
@ -13,6 +13,16 @@ const items = ref<ExecutionEvidence[]>([])
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const detailLoading = ref<Record<string, boolean>>({})
|
||||
const detailErrors = ref<Record<string, string>>({})
|
||||
const checkFields = ref<Record<string, string>>({})
|
||||
const checkLoading = ref<Record<string, boolean>>({})
|
||||
const checkErrors = ref<Record<string, string>>({})
|
||||
const checkResults = ref<Record<string, ArtifactJsonCheck>>({})
|
||||
function clearChecks() {
|
||||
checkFields.value = {}
|
||||
checkLoading.value = {}
|
||||
checkErrors.value = {}
|
||||
checkResults.value = {}
|
||||
}
|
||||
let generation = 0
|
||||
|
||||
async function load(more = false) {
|
||||
@ -20,6 +30,7 @@ async function load(more = false) {
|
||||
const request = ++generation
|
||||
detailLoading.value = {}
|
||||
detailErrors.value = {}
|
||||
clearChecks()
|
||||
loading.value = true
|
||||
errorKey.value = ''
|
||||
try {
|
||||
@ -65,6 +76,41 @@ async function loadDetail(id: string, event: Event) {
|
||||
if (request === generation) delete detailLoading.value[id]
|
||||
}
|
||||
}
|
||||
async function checkJson(id: string) {
|
||||
if (checkLoading.value[id]) return
|
||||
const fields = (checkFields.value[id] ?? '').split(/\r?\n/).filter(field => field.length > 0)
|
||||
delete checkResults.value[id]
|
||||
delete checkErrors.value[id]
|
||||
if (!fields.length || fields.length > 16 || new Set(fields).size !== fields.length
|
||||
|| fields.some(field => !field.trim() || field.length > 128 || /[\x00-\x1f\x7f]/.test(field))) {
|
||||
checkErrors.value[id] = 'executionEvidence.jsonCheck.inputError'
|
||||
return
|
||||
}
|
||||
const request = generation
|
||||
checkLoading.value[id] = true
|
||||
try {
|
||||
const { data } = await executionEvidenceApi.checkJson(id, fields)
|
||||
if (request !== generation || !items.value.some(item => item.id === id)) return
|
||||
checkResults.value[id] = data
|
||||
if (data.status === 'UNAVAILABLE') {
|
||||
items.value = items.value.map(item => item.id === id
|
||||
? { ...item, artifactRef: null, artifactDigest: null, summary: null, validity: 'UNAVAILABLE' } : item)
|
||||
}
|
||||
} catch (error) {
|
||||
if (request !== generation) return
|
||||
const failure = error as { code?: number; response?: { status?: number } }
|
||||
const code = failure.response?.status ?? failure.code
|
||||
if (code === 401 || code === 403 || code === 404) {
|
||||
items.value = items.value.filter(item => item.id !== id)
|
||||
delete checkFields.value[id]
|
||||
errorKey.value = 'executionEvidence.accessError'
|
||||
} else {
|
||||
checkErrors.value[id] = code === 400 ? 'executionEvidence.jsonCheck.inputError' : 'executionEvidence.loadError'
|
||||
}
|
||||
} finally {
|
||||
if (request === generation) delete checkLoading.value[id]
|
||||
}
|
||||
}
|
||||
function toggle() {
|
||||
expanded.value = !expanded.value
|
||||
if (expanded.value && !loaded.value) void load()
|
||||
@ -74,6 +120,7 @@ watch(() => [props.conversationId, props.goalId, props.teamTaskId], () => {
|
||||
items.value = []
|
||||
detailLoading.value = {}
|
||||
detailErrors.value = {}
|
||||
clearChecks()
|
||||
nextCursor.value = null
|
||||
errorKey.value = ''
|
||||
loaded.value = false
|
||||
@ -119,6 +166,21 @@ onBeforeUnmount(() => { generation++ })
|
||||
<div v-if="item.artifactRef"><dt>{{ t('executionEvidence.artifactRef') }}</dt><dd>{{ item.artifactRef }}</dd></div>
|
||||
<div v-if="item.artifactDigest"><dt>{{ t('executionEvidence.digest') }}</dt><dd>{{ item.artifactDigest }}</dd></div>
|
||||
</dl>
|
||||
<form v-if="item.kind === 'ARTIFACT_SNAPSHOT' && item.artifactRef" class="json-check" @submit.prevent="checkJson(item.id)">
|
||||
<label :for="`json-fields-${item.id}`">{{ t('executionEvidence.jsonCheck.label') }}</label>
|
||||
<textarea :id="`json-fields-${item.id}`" v-model="checkFields[item.id]" data-json-fields rows="3" maxlength="2064"
|
||||
:disabled="!!checkLoading[item.id]" :placeholder="t('executionEvidence.jsonCheck.placeholder')"
|
||||
@input="delete checkResults[item.id]" />
|
||||
<p>{{ t('executionEvidence.jsonCheck.scope') }}</p>
|
||||
<button type="submit" data-json-check :disabled="!!checkLoading[item.id]">{{ t(checkLoading[item.id] ? 'common.loading' : 'executionEvidence.jsonCheck.run') }}</button>
|
||||
</form>
|
||||
<p v-if="checkErrors[item.id]" role="alert">{{ t(checkErrors[item.id]) }}</p>
|
||||
<div v-if="checkResults[item.id]" data-json-result role="status">
|
||||
<p>{{ t(`executionEvidence.jsonCheck.status.${checkResults[item.id]!.status}`) }}</p>
|
||||
<p v-if="checkResults[item.id]!.missingFields.length">{{ checkResults[item.id]!.missingFields.join(', ') }}</p>
|
||||
<p>{{ t('executionEvidence.jsonCheck.limitation') }}</p>
|
||||
<time :datetime="checkResults[item.id]!.checkedAt">{{ checkResults[item.id]!.checkedAt }}</time>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
</ol>
|
||||
@ -142,5 +204,10 @@ dl > div { display: grid; grid-template-columns: minmax(75px, 1fr) minmax(0, 2fr
|
||||
dt { color: var(--mc-text-tertiary); }
|
||||
dd { margin: 0; overflow-wrap: anywhere; }
|
||||
summary { cursor: pointer; }
|
||||
.json-check { display: grid; gap: 6px; margin-top: 12px; }
|
||||
.json-check p { margin: 0; line-height: 1.5; }
|
||||
.json-check textarea { width: 100%; box-sizing: border-box; resize: vertical; font: inherit; color: var(--mc-text-primary); background: transparent; border: 1px solid var(--mc-border-light); border-radius: 6px; padding: 6px; }
|
||||
.json-check textarea:focus-visible { outline: 2px solid var(--mc-primary); }
|
||||
.json-check button { justify-self: start; }
|
||||
[role="alert"] { color: var(--mc-danger, #b53535); }
|
||||
</style>
|
||||
|
||||
@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import ExecutionEvidenceList from '../ExecutionEvidenceList.vue'
|
||||
import { executionEvidenceApi } from '@/api/executionEvidence'
|
||||
import en from '@/i18n/locales/en-US'
|
||||
vi.mock('@/api/executionEvidence', () => ({ executionEvidenceApi: { list: vi.fn(), get: vi.fn() } }))
|
||||
vi.mock('@/api/executionEvidence', () => ({ executionEvidenceApi: { list: vi.fn(), get: vi.fn(), checkJson: vi.fn() } }))
|
||||
const apps: ReturnType<typeof createApp>[] = []
|
||||
const row = (id: string) => ({ id, attemptId: '9223372036854775801', conversationId: 'one', toolName: 'execCommand', state: 'SUCCEEDED', effectOutcome: 'CONFIRMED', kind: 'COMMAND_EXIT', result: 'OBSERVED', sourceLevel: 'RUNTIME', validity: 'UNKNOWN', summary: 'Exit code: 0', observedAt: '2026-09-07T12:00:00Z', expiresAt: null, artifactRef: null, artifactDigest: null })
|
||||
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
||||
@ -72,4 +72,54 @@ describe('execution evidence', () => {
|
||||
expect(host.querySelector('[role="alert"]')?.textContent).toContain('permission')
|
||||
})
|
||||
|
||||
it('runs an explicit JSON check and clears the result when requirements change', async () => {
|
||||
const artifact = { ...row('artifact'), kind: 'ARTIFACT_SNAPSHOT', artifactRef: 'file', artifactDigest: 'hash' }
|
||||
vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [artifact], nextCursor: null } } as never)
|
||||
vi.mocked(executionEvidenceApi.checkJson).mockResolvedValue({ data: { recipeId: 'json-required-fields', recipeRevision: 1,
|
||||
status: 'MATCH', requiredFields: ['report'], missingFields: [], checkedAt: '2026-09-13T14:00:00Z', acceptanceEligible: false } } as never)
|
||||
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
|
||||
expect(executionEvidenceApi.checkJson).not.toHaveBeenCalled()
|
||||
const field = host.querySelector<HTMLTextAreaElement>('[data-json-fields]')!
|
||||
field.value = 'report'; field.dispatchEvent(new Event('input')); await flush()
|
||||
host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); await flush()
|
||||
expect(executionEvidenceApi.checkJson).toHaveBeenCalledWith('artifact', ['report'])
|
||||
expect(host.querySelector('[data-json-result]')?.textContent).toContain('All listed fields are present')
|
||||
expect(host.querySelector('[data-json-result]')?.textContent).toContain('does not complete or verify a goal')
|
||||
field.value = 'appendix'; field.dispatchEvent(new Event('input')); await flush()
|
||||
expect(host.querySelector('[data-json-result]')).toBeNull()
|
||||
})
|
||||
it('discards a JSON check response after changing conversations', async () => {
|
||||
let resolve!: (value: unknown) => void
|
||||
vi.mocked(executionEvidenceApi.list).mockResolvedValueOnce({ data: { items: [{ ...row('artifact'), kind: 'ARTIFACT_SNAPSHOT', artifactRef: 'file' }], nextCursor: null } } as never)
|
||||
.mockResolvedValueOnce({ data: { items: [row('new')], nextCursor: null } } as never)
|
||||
vi.mocked(executionEvidenceApi.checkJson).mockImplementationOnce(() => new Promise(r => { resolve = r }) as never)
|
||||
const { host, props } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
|
||||
const field = host.querySelector<HTMLTextAreaElement>('[data-json-fields]')!
|
||||
field.value = 'report'; field.dispatchEvent(new Event('input')); await flush()
|
||||
host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); await flush()
|
||||
props.conversationId = 'two'; await flush()
|
||||
resolve({ data: { status: 'MATCH', missingFields: [], checkedAt: 'old' } }); await flush()
|
||||
expect(host.querySelector('[data-json-result]')).toBeNull()
|
||||
expect(host.textContent).not.toContain('All listed fields are present')
|
||||
})
|
||||
it('rejects empty JSON requirements locally without reading the file', async () => {
|
||||
vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [{ ...row('artifact'), kind: 'ARTIFACT_SNAPSHOT', artifactRef: 'file' }], nextCursor: null } } as never)
|
||||
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
|
||||
host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); await flush()
|
||||
expect(executionEvidenceApi.checkJson).not.toHaveBeenCalled()
|
||||
expect(host.querySelector('[role="alert"]')?.textContent).toContain('unique field names')
|
||||
})
|
||||
|
||||
it('removes artifact details when JSON check authorization is revoked', async () => {
|
||||
vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [{ ...row('artifact'), kind: 'ARTIFACT_SNAPSHOT', artifactRef: 'file' }], nextCursor: null } } as never)
|
||||
vi.mocked(executionEvidenceApi.checkJson).mockRejectedValue({ code: 403 })
|
||||
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
|
||||
const field = host.querySelector<HTMLTextAreaElement>('[data-json-fields]')!
|
||||
field.value = 'report'; field.dispatchEvent(new Event('input')); await flush()
|
||||
host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); await flush()
|
||||
expect(host.querySelector('[data-evidence-item]')).toBeNull()
|
||||
expect(host.querySelector('[data-json-result]')).toBeNull()
|
||||
expect(host.querySelector('[role="alert"]')?.textContent).toContain('permission')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
export default {
|
||||
executionEvidence: {
|
||||
jsonCheck: {
|
||||
label: 'Required JSON fields', placeholder: 'One top-level field per line', run: 'Check JSON file',
|
||||
scope: 'Checks a JSON object for the listed top-level fields with non-null values. Up to 16 fields and 1 MiB.',
|
||||
inputError: 'Enter 1–16 unique field names, one per line, up to 128 characters each.',
|
||||
limitation: 'This result describes the bytes read at the time shown. It does not complete or verify a goal.',
|
||||
status: { MATCH: 'All listed fields are present.', MISSING_FIELDS: 'Required fields are missing or null:',
|
||||
INVALID_JSON: 'The file is not a supported JSON object.', UNKNOWN: 'The check could not run within the configured limits.',
|
||||
STALE: 'The file differs from the registered snapshot.', UNAVAILABLE: 'The file is unavailable or access has changed.' },
|
||||
},
|
||||
scope: 'Working directory / check scope',
|
||||
title: 'Execution evidence', observationOnly: 'Runtime observations only. A tool return or command exit does not verify the goal or task. Coverage is limited to recorded sources.',
|
||||
refresh: 'Refresh', loadMore: 'Load more', empty: 'No execution evidence recorded.',
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
export default {
|
||||
executionEvidence: {
|
||||
jsonCheck: {
|
||||
label: 'JSON 必需字段', placeholder: '每行一个顶层字段名', run: '检查 JSON 文件',
|
||||
scope: '检查 JSON 对象是否包含指定的顶层字段且值非 null。最多 16 个字段、1 MiB 文件。',
|
||||
inputError: '请输入 1–16 个不重复的字段名,每行一个,每项不超过 128 字符。',
|
||||
limitation: '结果仅针对此时间读取到的文件内容,不会完成目标或使目标通过验收。',
|
||||
status: { MATCH: '指定字段均存在。', MISSING_FIELDS: '以下字段缺失或值为 null:',
|
||||
INVALID_JSON: '文件不是支持的 JSON 对象。', UNKNOWN: '未能在配置限制内完成检查。',
|
||||
STALE: '文件内容与登记快照不同。', UNAVAILABLE: '文件不可用或访问权限已变化。' },
|
||||
},
|
||||
scope: '执行目录 / 检查范围',
|
||||
title: '执行证据', observationOnly: '仅展示运行时观察记录。工具返回或命令退出不代表目标或任务已验证。覆盖范围仅限已记录的来源。',
|
||||
refresh: '刷新', loadMore: '加载更多', empty: '暂无执行证据记录。',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user