mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(daemon): guard against agent-emitted stub artifact regressions
When an agent emits an <artifact> block whose body is a placeholder
("see other-file.html in this project", a bare filename string, a tiny
fallback page) instead of the full document, the daemon writes the
placeholder to disk verbatim. Users see a 25-500 byte HTML file where
their previous version had tens of kilobytes of real markup.
Add a structural regression guard in writeProjectFile: before writing
an html/deck artifact whose manifest carries metadata.identifier, scan
the project dir for prior siblings matching <identifier>(-\d+)?\.html?
and compare sizes. If the new body is below minRetainedRatio (default
0.2) of the largest prior sibling >= minPriorBytes (default 4096),
flag a regression. Three modes via env:
- OD_ARTIFACT_STUB_GUARD=warn (default) writes the file and attaches
stubGuardWarning to the response so the frontend can surface it.
- OD_ARTIFACT_STUB_GUARD=reject throws ArtifactRegressionError before
fs.writeFile; the route returns 422 ARTIFACT_REGRESSION with the
prior sibling's name and size in error.details.
- OD_ARTIFACT_STUB_GUARD=off skips the guard entirely.
Cross-agent by design: anchored on size delta + identifier match,
no agent-specific stub-phrase regex, so works for any agent backend
behind the agent-adapter abstraction.
The body-then-manifest write order pre-dates this change; the reject
path throws before fs.writeFile so rejections never leave a partial
state behind.
24 unit + 8 HTTP tests cover happy paths, all three modes, deck kind,
.htm extension sibling detection, ratio=1 edge case, and verify
rejected writes leave neither the html nor its manifest sidecar on
disk.
* fix(stub-guard): close same-name, nested-dir, and non-slug bypasses
Code review on PR #1171 (lefarcen, Codex, mrcfps) found three holes
where the stub guard could be silently bypassed. All three are now
closed with HTTP test coverage.
Same-name overwrite (lefarcen P1): the writer's prior-sibling scan
deliberately skipped the file at safeName, but for an in-session
overwrite (persistArtifact reuses the same fileName when
savedArtifactRef.current matches) that file is the prior content,
not the new entry. Drop the exclude-by-name filter; the current
on-disk size at scan time is always the prior because the overwrite
happens after this check.
Subdirectory scoping (Codex/mrcfps P2): writeProjectFile creates
parent directories for nested paths like reports/overview.html, but
the guard only scanned the project root. Pass path.dirname(target)
as scanDir so nested artifacts are evaluated against their real
sibling set.
Non-slug identifier (Codex/lefarcen/mrcfps P2): the web's
persistArtifact slugifies the filename basename but stores the raw
identifier in the manifest, so an identifier like "Landing Page"
yields filename landing-page.html with metadata.identifier="Landing
Page". Build the sibling regex from both the raw identifier and a
slugified variant (mirroring the frontend's slugifier) so either
form matches the same priors.
Also surface warn-mode warnings in the web UI: ProjectView now
checks file.stubGuardWarning after writeProjectTextFile and renders
the warning via setError. Reject-mode 422 surfacing requires
restructuring writeProjectTextFile's return contract and is
deferred.
API change inside the daemon: evaluateArtifactStubGuard /
findPriorArtifactSiblings drop excludeSafeName and rename projectDir
to scanDir. Tests updated.
Tests: 4 new HTTP cases (same-name overwrite preserves prior body,
nested subdir rejects, slug-form match rejects, plus the existing
warn/off/deck/.htm cases) and 1 new unit case (slug-form sibling
match). 44 tests pass.
* fix(stub-guard): empty-slug fallback + reject-mode UI surface
Round 3 review on PR #1171 (lefarcen, mrcfps) found two remaining
holes after 9cc82430 closed the same-name / subdir / non-slug bypasses.
Empty-slug fallback bypass (lefarcen P2): an identifier like "测试"
(all-non-ASCII) strips to empty through the web slugifier, and
persistArtifact's `slice(0,60) || 'artifact'` falls back to the
literal "artifact" basename. The guard searched for raw identifier +
slug only, so a later artifact-2.html stub bypassed the prior. Add
EMPTY_SLUG_FALLBACK_NAME = 'artifact' as a sibling-name candidate
when the slug is empty, mirroring the frontend fallback exactly.
Reject-mode UI silence (mrcfps P2 + lefarcen P2): writeProjectTextFile
collapses any non-OK response (including 422 ARTIFACT_REGRESSION) to
null, and persistArtifact previously had no else branch. Users in
reject mode saw the daemon log fire but the UI was silent. Add an
else branch that surfaces a generic banner pointing at the most
likely cause and mentions checking the daemon logs for structured
details. Also clear savedArtifactRef.current on failure so retries
re-enter the persistence path.
Plumbing the structured 422 details through writeProjectTextFile
itself remains out of scope (cross-cutting client contract change
affecting 5+ call sites). The generic banner is the "at minimum"
path mrcfps suggested.
Tests: 1 new unit case (artifact.html sibling discovery for non-ASCII
identifier) + 1 new HTTP case (empty-slug stub regression rejected
end-to-end). 46 tests pass across stub-guard suites (was 44).
* fix(stub-guard): verify sidecar identity to avoid cross-identifier false positives
Round 4 review on PR #1171 (mrcfps inline + lefarcen review) caught
a false-positive introduced by the round-3 empty-slug fallback. Two
distinct identifiers that both slugify to empty (e.g. "测试" and
"首页") share the artifact*.html basename, so a brand-new save under
the second identifier was being compared against — and falsely
rejected because of — the unrelated first.
The same shape exists symmetrically: a non-empty-slug identifier
literally named "artifact" would falsely match empty-slug fallback
files written under any other identifier.
Fix: filename pattern matching is now a candidate generator, not
the source of truth. For every candidate sibling, read its
.artifact.json sidecar and verify metadata.identifier matches the
input via artifactIdentifiersMatch (raw equality OR shared non-empty
slug). Files without a sidecar are skipped — they weren't written
through the artifact-tag path this guard targets, and treating them
as priors was always a stretch.
Empty-slug equivalence is intentionally NOT honored: 测试 != 首页
even though both slugify to empty. The whole bug was conflating
distinct identifiers via the fallback name; slug-equivalence kicks
in only for non-empty slugs (Landing Page <-> landing-page).
Tests: unit fixtures now write file+sidecar pairs (mirrors prod);
new artifactIdentifiersMatch suite covers the 5 equivalence cases;
new HTTP test does NOT cross-reject distinct empty-slug identifiers
asserts the second save returns 200 instead of 422; new unit test
skips files without a sidecar.
42 tests pass across stub-guard suites.
* fix(stub-guard): require canonical-form anchor in identifier match to avoid 60-char truncation collisions
Round 5 review on PR #1171 (mrcfps) caught another false-positive in
artifactIdentifiersMatch: slugifyArtifactIdentifier truncates at 60
chars, so two distinct >60-char identifiers that share their first
60 chars (e.g. "A...A1" and "A...A2", 70 chars each) slugify to the
same string and would falsely bridge. Same shape as the empty-slug
fallback bug from round 4, just at the other end of the input range.
Tighten the rule: slug-equivalence requires at least one input to BE
its own canonical slug form. That keeps the legitimate bridge
("Landing Page" <-> "landing-page" — second input IS the slug) but
rejects truncation collisions ("A...A1" <-> "A...A2" — neither is in
canonical form).
Side effect: two non-canonical forms that slugify to the same value
no longer bridge (e.g. "Landing Page" vs "LANDING-PAGE"). This is
correct: without one canonical anchor we can't safely call them the
same lineage. Updated the slug-equivalence test to assert the new
semantics explicitly with both directions and a negative case.
Tests: 2 new cases (no bridge for >60-char truncation collision; raw
70-char to its 60-char truncated slug still bridges) + 1 negative
test for the non-canonical-pair case. 45 tests pass.
* fix(stub-guard): cover legacy sidecar-less HTML priors
Round 6 review on PR #1171 (mrcfps, non-blocking) caught a real
legacy bypass: round 4's sidecar-required policy skipped any HTML
file without an .artifact.json companion, but readManifestForPath
(projects.ts) treats those same files as legitimate artifacts via
inferLegacyManifest. So a project with an older sidecar-less
dashboard.html (pre-sidecar era, Write-tool-emitted, paste-text,
manual import, etc.) let its first stub rewrite through as a
supposed "first emission".
Fix: when the sidecar is missing, derive a synthetic identifier
from the filename (strip the (-N)?\.html? suffix) and run it
through the same artifactIdentifiersMatch rules. Synthetic
identifiers come from already-slugified filenames, so they bridge
raw inputs only via the canonical-form rule established in round
5 — no truncation collisions, no empty-slug conflation, no
unrelated cross-identifier matches.
Tests: 3 new unit cases (legacy fallback finds the prior; bridges
raw->slug under the same rules; does NOT bridge unrelated slug
forms via inference) + 1 new HTTP test that seeds a sidecar-less
prior via the artifact-manifest-less write path and asserts the
stub rewrite is rejected with 422 ARTIFACT_REGRESSION.
48 tests pass across stub-guard suites (was 45).
* fix(stub-guard): try both interpretations for legacy filename inference
Round 7 review on PR #1171 (mrcfps, non-blocking) caught a real
ambiguity in the round-6 legacy fallback: a filename like
`phase-2.html` is genuinely ambiguous without a sidecar. It could
be the identifier "phase" with a -2 collision suffix, OR the
standalone identifier "phase-2". The round-6 helper only stripped
the suffix, so a sidecar-less `phase-2.html` followed by a stub
emission with metadata.identifier="phase-2" bypassed the guard
("phase-2" doesn't match the inferred "phase").
Fix: when the sidecar is missing, generate both candidate
identifiers (full basename and suffix-stripped basename) and
accept the file as a prior if either matches. Visible false
positives are preferable to silent false negatives — and the
canonical-form anchor in artifactIdentifiersMatch still rules out
truncation collisions and empty-slug conflations regardless of
which candidate matched.
Tests: 2 new unit cases (full-basename interpretation finds
"phase-2"; suffix-stripped interpretation also finds "phase") and
1 new HTTP test that seeds a sidecar-less `phase-2.html` and
asserts the stub rewrite is rejected with 422 ARTIFACT_REGRESSION.
51 tests pass across stub-guard suites (was 48).
---------
Co-authored-by: Sebastian Westberg <sebastianwestberg@users.noreply.github.com>
427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
import type http from 'node:http';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { startServer } from '../src/server.js';
|
|
|
|
interface FilePostBody {
|
|
name: string;
|
|
content: string;
|
|
encoding?: 'utf8' | 'base64';
|
|
artifactManifest?: unknown;
|
|
}
|
|
|
|
function htmlBody(byteLength: number): string {
|
|
const filler = 'x'.repeat(Math.max(0, byteLength - 96));
|
|
return `<!doctype html><html><head><title>Doc</title></head><body><main>${filler}</main></body></html>`;
|
|
}
|
|
|
|
function manifestFor(identifier: string, kind: 'html' | 'deck' = 'html') {
|
|
return {
|
|
kind,
|
|
renderer: kind === 'deck' ? 'deck-html' : 'html',
|
|
title: identifier,
|
|
exports: kind === 'deck' ? ['html', 'pdf'] : ['html'],
|
|
metadata: { identifier },
|
|
};
|
|
}
|
|
|
|
describe('artifact stub guard via /api/projects/:id/files', () => {
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
vi.stubEnv('OD_ARTIFACT_STUB_GUARD', 'reject');
|
|
vi.stubEnv('OD_ARTIFACT_STUB_GUARD_MIN_PRIOR_BYTES', '1024');
|
|
vi.stubEnv('OD_ARTIFACT_STUB_GUARD_MIN_RATIO', '0.2');
|
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
|
url: string;
|
|
server: http.Server;
|
|
};
|
|
baseUrl = started.url;
|
|
server = started.server;
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.unstubAllEnvs();
|
|
return new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Each test resets the mode it changed; default back to reject.
|
|
vi.stubEnv('OD_ARTIFACT_STUB_GUARD', 'reject');
|
|
});
|
|
|
|
async function createProject(prefix: string) {
|
|
const id = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, name: id }),
|
|
});
|
|
expect(resp.status).toBe(200);
|
|
return id;
|
|
}
|
|
|
|
async function postFile(projectId: string, body: FilePostBody) {
|
|
return fetch(`${baseUrl}/api/projects/${projectId}/files`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
it('rejects a stub-sized rewrite with ARTIFACT_REGRESSION', async () => {
|
|
const projectId = await createProject('reject');
|
|
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'dashboard.html',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: manifestFor('dashboard'),
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'dashboard-2.html',
|
|
content: 'See dashboard.html in this project — full standalone file written to disk.',
|
|
artifactManifest: manifestFor('dashboard'),
|
|
});
|
|
|
|
expect(stubResp.status).toBe(422);
|
|
const stubBody = (await stubResp.json()) as {
|
|
error: { code: string; message: string; details?: { identifier?: string; priorName?: string } };
|
|
};
|
|
expect(stubBody.error.code).toBe('ARTIFACT_REGRESSION');
|
|
expect(stubBody.error.details?.identifier).toBe('dashboard');
|
|
expect(stubBody.error.details?.priorName).toBe('dashboard.html');
|
|
});
|
|
|
|
it('does not write the new file or its manifest when rejected', async () => {
|
|
const projectId = await createProject('not-written');
|
|
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'report.html',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: manifestFor('report'),
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'report-2.html',
|
|
content: '<html><body>see report.html</body></html>',
|
|
artifactManifest: manifestFor('report'),
|
|
});
|
|
expect(stubResp.status).toBe(422);
|
|
|
|
const listResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
|
|
expect(listResp.status).toBe(200);
|
|
const listBody = (await listResp.json()) as { files: Array<{ name: string }> };
|
|
const names = listBody.files.map((f) => f.name).sort();
|
|
expect(names).not.toContain('report-2.html');
|
|
expect(names).not.toContain('report-2.html.artifact.json');
|
|
expect(names).toContain('report.html');
|
|
});
|
|
|
|
it('allows a same-size revision through', async () => {
|
|
const projectId = await createProject('allow');
|
|
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'landing-page.html',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: manifestFor('landing-page'),
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
const secondResp = await postFile(projectId, {
|
|
name: 'landing-page-2.html',
|
|
content: htmlBody(20_500),
|
|
artifactManifest: manifestFor('landing-page'),
|
|
});
|
|
expect(secondResp.status).toBe(200);
|
|
});
|
|
|
|
it('warns instead of rejecting when guard mode is warn', async () => {
|
|
vi.stubEnv('OD_ARTIFACT_STUB_GUARD', 'warn');
|
|
const projectId = await createProject('warn');
|
|
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'overview.html',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: manifestFor('overview'),
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'overview-2.html',
|
|
content: '<html><body>placeholder</body></html>',
|
|
artifactManifest: manifestFor('overview'),
|
|
});
|
|
expect(stubResp.status).toBe(200);
|
|
|
|
const writeBody = (await stubResp.json()) as {
|
|
file: { name: string; stubGuardWarning?: { code: string; identifier: string } };
|
|
};
|
|
expect(writeBody.file.name).toBe('overview-2.html');
|
|
expect(writeBody.file.stubGuardWarning?.code).toBe('ARTIFACT_REGRESSION');
|
|
expect(writeBody.file.stubGuardWarning?.identifier).toBe('overview');
|
|
});
|
|
|
|
it('skips the guard entirely when mode is off', async () => {
|
|
vi.stubEnv('OD_ARTIFACT_STUB_GUARD', 'off');
|
|
const projectId = await createProject('off');
|
|
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'briefing.html',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: manifestFor('briefing'),
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'briefing-2.html',
|
|
content: '<html><body>see briefing.html</body></html>',
|
|
artifactManifest: manifestFor('briefing'),
|
|
});
|
|
expect(stubResp.status).toBe(200);
|
|
|
|
const writeBody = (await stubResp.json()) as { file: { stubGuardWarning?: unknown } };
|
|
expect(writeBody.file.stubGuardWarning).toBeUndefined();
|
|
});
|
|
|
|
it('accepts a stub-sized first emission of a new identifier', async () => {
|
|
const projectId = await createProject('first');
|
|
|
|
const resp = await postFile(projectId, {
|
|
name: 'changelog.html',
|
|
content: '<html><body>tiny</body></html>',
|
|
artifactManifest: manifestFor('changelog'),
|
|
});
|
|
expect(resp.status).toBe(200);
|
|
});
|
|
|
|
it('rejects a stub rewrite of a deck artifact (kind: deck)', async () => {
|
|
const projectId = await createProject('deck');
|
|
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'kickoff-deck.html',
|
|
content: htmlBody(40_000),
|
|
artifactManifest: manifestFor('kickoff-deck', 'deck'),
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'kickoff-deck-2.html',
|
|
content: '<html><body>see kickoff-deck.html</body></html>',
|
|
artifactManifest: manifestFor('kickoff-deck', 'deck'),
|
|
});
|
|
expect(stubResp.status).toBe(422);
|
|
const body = (await stubResp.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('ARTIFACT_REGRESSION');
|
|
});
|
|
|
|
it('detects prior siblings written with .htm extension', async () => {
|
|
const projectId = await createProject('htm');
|
|
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'overview-doc.htm',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: manifestFor('overview-doc'),
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'overview-doc-2.html',
|
|
content: '<html><body>see overview-doc.htm</body></html>',
|
|
artifactManifest: manifestFor('overview-doc'),
|
|
});
|
|
expect(stubResp.status).toBe(422);
|
|
const body = (await stubResp.json()) as {
|
|
error: { code: string; details?: { priorName?: string } };
|
|
};
|
|
expect(body.error.code).toBe('ARTIFACT_REGRESSION');
|
|
expect(body.error.details?.priorName).toBe('overview-doc.htm');
|
|
});
|
|
|
|
it('rejects a same-name overwrite that shrinks the existing file (lefarcen P1)', async () => {
|
|
const projectId = await createProject('overwrite');
|
|
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'dashboard.html',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: manifestFor('dashboard'),
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
// Same name, same identifier, stub body: existing file is the prior.
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'dashboard.html',
|
|
content: '<html><body>see dashboard.html</body></html>',
|
|
artifactManifest: manifestFor('dashboard'),
|
|
});
|
|
expect(stubResp.status).toBe(422);
|
|
const body = (await stubResp.json()) as {
|
|
error: { code: string; details?: { priorName?: string } };
|
|
};
|
|
expect(body.error.code).toBe('ARTIFACT_REGRESSION');
|
|
expect(body.error.details?.priorName).toBe('dashboard.html');
|
|
|
|
// Confirm the original 20 KB file is intact (not overwritten).
|
|
const filesResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
|
|
const files = (await filesResp.json()) as { files: Array<{ name: string; size: number }> };
|
|
const dashboard = files.files.find((f) => f.name === 'dashboard.html');
|
|
expect(dashboard?.size).toBeGreaterThan(15_000);
|
|
});
|
|
|
|
it('rejects stub regressions in subdirectories (Codex/mrcfps P2)', async () => {
|
|
const projectId = await createProject('nested');
|
|
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'reports/overview.html',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: manifestFor('overview'),
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'reports/overview-2.html',
|
|
content: '<html><body>see reports/overview.html</body></html>',
|
|
artifactManifest: manifestFor('overview'),
|
|
});
|
|
expect(stubResp.status).toBe(422);
|
|
const body = (await stubResp.json()) as {
|
|
error: { code: string; details?: { priorName?: string } };
|
|
};
|
|
expect(body.error.code).toBe('ARTIFACT_REGRESSION');
|
|
expect(body.error.details?.priorName).toBe('overview.html');
|
|
});
|
|
|
|
it('finds slug-form sibling when manifest carries non-slug identifier (Codex/lefarcen/mrcfps P2)', async () => {
|
|
const projectId = await createProject('slug');
|
|
|
|
// Frontend wrote the previous artifact under a slugified name but the
|
|
// manifest carried the raw identifier "Landing Page".
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'landing-page.html',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: { ...manifestFor('landing-page'), metadata: { identifier: 'Landing Page' } },
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'landing-page-2.html',
|
|
content: '<html><body>see landing-page.html</body></html>',
|
|
artifactManifest: { ...manifestFor('landing-page'), metadata: { identifier: 'Landing Page' } },
|
|
});
|
|
expect(stubResp.status).toBe(422);
|
|
const body = (await stubResp.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('ARTIFACT_REGRESSION');
|
|
});
|
|
|
|
it('finds artifact.html siblings when identifier slugifies to empty (lefarcen P2)', async () => {
|
|
const projectId = await createProject('empty-slug');
|
|
|
|
// Frontend persistArtifact slugifies "测试" -> "" -> falls back to
|
|
// "artifact", so the file lands as artifact.html. A subsequent stub
|
|
// emission with the same non-ASCII identifier must still find the
|
|
// prior via the empty-slug fallback.
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'artifact.html',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: { ...manifestFor('artifact'), metadata: { identifier: '测试' } },
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'artifact-2.html',
|
|
content: '<html><body>see artifact.html</body></html>',
|
|
artifactManifest: { ...manifestFor('artifact'), metadata: { identifier: '测试' } },
|
|
});
|
|
expect(stubResp.status).toBe(422);
|
|
const body = (await stubResp.json()) as {
|
|
error: { code: string; details?: { priorName?: string } };
|
|
};
|
|
expect(body.error.code).toBe('ARTIFACT_REGRESSION');
|
|
expect(body.error.details?.priorName).toBe('artifact.html');
|
|
});
|
|
|
|
it('does NOT cross-reject distinct empty-slug identifiers (lefarcen/mrcfps round 4)', async () => {
|
|
const projectId = await createProject('empty-slug-distinct');
|
|
|
|
// First save: identifier "测试", lands as artifact.html with a 20 KB
|
|
// body. Sidecar carries identifier="测试".
|
|
const firstResp = await postFile(projectId, {
|
|
name: 'artifact.html',
|
|
content: htmlBody(20_000),
|
|
artifactManifest: { ...manifestFor('artifact'), metadata: { identifier: '测试' } },
|
|
});
|
|
expect(firstResp.status).toBe(200);
|
|
|
|
// Second save: a *different* non-ASCII identifier "首页" that also
|
|
// slugifies to empty. This is a brand-new artifact lineage; the small
|
|
// first-emission body must not be compared against the unrelated
|
|
// "测试" prior just because both share the artifact*.html namespace.
|
|
const secondResp = await postFile(projectId, {
|
|
name: 'artifact-2.html',
|
|
content: '<html><body>tiny but legitimate first emission</body></html>',
|
|
artifactManifest: { ...manifestFor('artifact'), metadata: { identifier: '首页' } },
|
|
});
|
|
expect(secondResp.status).toBe(200);
|
|
});
|
|
|
|
it('catches stub rewrites of legacy sidecar-less HTML priors (mrcfps R6)', async () => {
|
|
const projectId = await createProject('legacy');
|
|
|
|
// Seed a "legacy" file by POSTing without artifactManifest — the
|
|
// route writes the body but no .artifact.json. Mirrors any HTML that
|
|
// pre-dates the sidecar era or was uploaded outside the artifact-tag
|
|
// flow (Write tool, paste-text, manual import).
|
|
const legacyResp = await postFile(projectId, {
|
|
name: 'dashboard.html',
|
|
content: htmlBody(20_000),
|
|
// no artifactManifest -> no sidecar on disk
|
|
});
|
|
expect(legacyResp.status).toBe(200);
|
|
|
|
// Now an agent emits a stub artifact with the matching identifier.
|
|
// Without the legacy fallback, the guard would skip the sidecar-less
|
|
// prior and let this through as a "first emission".
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'dashboard-2.html',
|
|
content: '<html><body>see dashboard.html</body></html>',
|
|
artifactManifest: manifestFor('dashboard'),
|
|
});
|
|
expect(stubResp.status).toBe(422);
|
|
const body = (await stubResp.json()) as {
|
|
error: { code: string; details?: { priorName?: string } };
|
|
};
|
|
expect(body.error.code).toBe('ARTIFACT_REGRESSION');
|
|
expect(body.error.details?.priorName).toBe('dashboard.html');
|
|
});
|
|
|
|
it('catches stub rewrites of legacy priors whose identifier ends in -<digits> (mrcfps R7)', async () => {
|
|
const projectId = await createProject('legacy-numeric');
|
|
|
|
// Seed a sidecar-less `phase-2.html` prior (the standalone
|
|
// identifier "phase-2", not "phase + collision suffix"). Without
|
|
// the dual-candidate fallback, syntheticIdentifierFromFilename
|
|
// would strip the -2 and the legacy fallback would search for
|
|
// "phase" instead, missing the prior and bypassing the guard.
|
|
const legacyResp = await postFile(projectId, {
|
|
name: 'phase-2.html',
|
|
content: htmlBody(20_000),
|
|
// no artifactManifest -> no sidecar on disk
|
|
});
|
|
expect(legacyResp.status).toBe(200);
|
|
|
|
const stubResp = await postFile(projectId, {
|
|
name: 'phase-2-2.html',
|
|
content: '<html><body>see phase-2.html</body></html>',
|
|
artifactManifest: manifestFor('phase-2'),
|
|
});
|
|
expect(stubResp.status).toBe(422);
|
|
const body = (await stubResp.json()) as {
|
|
error: { code: string; details?: { priorName?: string } };
|
|
};
|
|
expect(body.error.code).toBe('ARTIFACT_REGRESSION');
|
|
expect(body.error.details?.priorName).toBe('phase-2.html');
|
|
});
|
|
});
|