feat: add user-invoked bounded JSON artifact checks

This commit is contained in:
mateaix 2026-09-13 22:15:13 +08:00
parent 6a00c3869d
commit fb7bb04f7a
13 changed files with 393 additions and 3 deletions

View File

@ -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()));
}
}

View File

@ -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) {

View File

@ -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 116 unique top-level JSON fields, each 1128 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());
}
}
}

View File

@ -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();

View File

@ -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);
}

View File

@ -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());
}
}
}

View File

@ -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());
}
}

View File

@ -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'] })
})
})

View File

@ -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)}`),
}

View File

@ -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>

View File

@ -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')
})
})

View File

@ -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 116 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.',

View File

@ -1,5 +1,14 @@
export default {
executionEvidence: {
jsonCheck: {
label: 'JSON 必需字段', placeholder: '每行一个顶层字段名', run: '检查 JSON 文件',
scope: '检查 JSON 对象是否包含指定的顶层字段且值非 null。最多 16 个字段、1 MiB 文件。',
inputError: '请输入 116 个不重复的字段名,每行一个,每项不超过 128 字符。',
limitation: '结果仅针对此时间读取到的文件内容,不会完成目标或使目标通过验收。',
status: { MATCH: '指定字段均存在。', MISSING_FIELDS: '以下字段缺失或值为 null',
INVALID_JSON: '文件不是支持的 JSON 对象。', UNKNOWN: '未能在配置限制内完成检查。',
STALE: '文件内容与登记快照不同。', UNAVAILABLE: '文件不可用或访问权限已变化。' },
},
scope: '执行目录 / 检查范围',
title: '执行证据', observationOnly: '仅展示运行时观察记录。工具返回或命令退出不代表目标或任务已验证。覆盖范围仅限已记录的来源。',
refresh: '刷新', loadMore: '加载更多', empty: '暂无执行证据记录。',