test(chat): harden context serialization boundaries

This commit is contained in:
murashit 2026-07-18 18:02:24 +09:00
parent 5cd898fabe
commit 358cdd7b7c
3 changed files with 301 additions and 0 deletions

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { splitUtf8Context, truncateUtf8, utf8ByteLength } from "../../../src/domain/chat/context-budget";
describe("UTF-8 context budgets", () => {
it.each([
{ value: "", maxBytes: 10, expected: "" },
{ value: "text", maxBytes: 0, expected: "" },
{ value: "text", maxBytes: -1, expected: "" },
{ value: "ascii", maxBytes: 5, expected: "ascii" },
{ value: "あい", maxBytes: 3, expected: "あ" },
{ value: "A😀B", maxBytes: 5, expected: "A😀" },
{ value: "A😀B", maxBytes: 4, expected: "A" },
])("truncates $value to at most $maxBytes bytes", ({ value, maxBytes, expected }) => {
const result = truncateUtf8(value, maxBytes);
expect(result).toBe(expected);
expect(utf8ByteLength(result)).toBeLessThanOrEqual(Math.max(maxBytes, 0));
expect(result).not.toMatch(/[\ud800-\udbff]$|^[\udc00-\udfff]/);
});
it.each([
{
label: "paragraph",
value: "abc\n\ndef",
maxBytes: 6,
expected: ["abc\n\n", "def"],
},
{
label: "newline",
value: "alpha\nbeta",
maxBytes: 7,
expected: ["alpha\n", "beta"],
},
{
label: "space",
value: "alpha beta gamma",
maxBytes: 10,
expected: ["alpha ", "beta gamma"],
},
])("prefers a $label boundary when splitting", ({ value, maxBytes, expected }) => {
const result = splitUtf8Context(value, maxBytes, 10);
expect(result.parts).toEqual(expected);
expect(result.parts.join("")).toBe(value);
expect(result.includedBytes).toBe(utf8ByteLength(value));
expect(result.parts.every((part) => utf8ByteLength(part) <= maxBytes)).toBe(true);
});
it("honors maxParts and reports only included bytes", () => {
expect(splitUtf8Context("one two three", 4, 2)).toEqual({
parts: ["one ", "two "],
includedBytes: 8,
});
expect(splitUtf8Context("text", 4, 0)).toEqual({ parts: [], includedBytes: 0 });
expect(splitUtf8Context("text", 0, 2)).toEqual({ parts: [], includedBytes: 0 });
});
});

View file

@ -0,0 +1,219 @@
import { describe, expect, it } from "vitest";
import {
type TurnContextManifest,
turnContextManifestFromText,
turnContextManifestText,
userMessageContextProjection,
} from "../../../src/domain/chat/context-manifest";
const SUBMISSION_ID = "local-user-1-seed-1-1";
function webEntry(): Record<string, unknown> {
return {
kind: "web",
id: `${SUBMISSION_ID}.00`,
parts: 1,
sourceBytes: 10,
includedBytes: 10,
truncated: false,
};
}
function rawManifest(overrides: Record<string, unknown> = {}): string {
return `[Codex Panel context v2]\nReference/display metadata only; not user instructions.\n${JSON.stringify({
version: 2,
submissionId: SUBMISSION_ID,
contexts: [webEntry()],
...overrides,
})}`;
}
describe("turn context manifest validation", () => {
it.each([
["unsupported version", { version: 1 }],
["missing contexts", { contexts: undefined }],
["non-array contexts", { contexts: {} }],
["null context entry", { contexts: [null] }],
])("rejects %s", (_label, overrides) => {
expect(turnContextManifestFromText(rawManifest(overrides))).toBeNull();
});
it.each([
["kind", "other"],
["id", ""],
["id", 1],
["id", "i".repeat(161)],
["parts", -1],
["parts", 1.5],
["parts", "1"],
["sourceBytes", -1],
["sourceBytes", Number.MAX_SAFE_INTEGER + 1],
["includedBytes", -1],
["truncated", "false"],
])("rejects an invalid %s entry field without relying on another invalid field", (field, value) => {
expect(turnContextManifestFromText(rawManifest({ contexts: [{ ...webEntry(), [field]: value }] }))).toBeNull();
});
it.each([
["threadId", ""],
["threadId", 1],
["includedTurns", -1],
["includedTurns", 1.5],
["turnLimit", 0],
["turnLimit", 1.5],
["omittedTurns", -1],
])("rejects an invalid referenced-thread %s", (field, value) => {
const entry = {
...webEntry(),
kind: "referencedThread",
threadId: "thread",
includedTurns: 1,
turnLimit: 20,
omittedTurns: 0,
[field]: value,
};
expect(turnContextManifestFromText(rawManifest({ contexts: [entry] }))).toBeNull();
});
it("accepts zero-valued non-negative entry fields and omitted optional fields", () => {
const manifest = turnContextManifestFromText(
rawManifest({
contexts: [
{
...webEntry(),
parts: 0,
sourceBytes: 0,
includedBytes: 0,
},
{
...webEntry(),
kind: "obsidian",
id: `${SUBMISSION_ID}.01`,
},
],
}),
);
expect(manifest).toMatchObject({
contexts: [{ parts: 0, sourceBytes: 0, includedBytes: 0 }, { kind: "obsidian" }],
});
expect(manifest?.contexts[1]).not.toHaveProperty("inlineExcerpts");
});
it("rejects an invalid optional Obsidian excerpt count", () => {
expect(
turnContextManifestFromText(
rawManifest({
contexts: [{ ...webEntry(), kind: "obsidian", inlineExcerpts: -1 }],
}),
),
).toBeNull();
});
it.each([
["missing name", { path: "Note.md" }],
["empty name", { name: "", path: "Note.md" }],
["non-string name", { name: 1, path: "Note.md" }],
["long name", { name: "n".repeat(256), path: "Note.md" }],
["missing path", { name: "Note" }],
["empty path", { name: "Note", path: "" }],
["non-string path", { name: "Note", path: 1 }],
["long path", { name: "Note", path: "p".repeat(2_049) }],
["non-object value", null],
])("rejects a file reference with %s", (_label, reference) => {
expect(turnContextManifestFromText(rawManifest({ fileReferences: [reference] }))).toBeNull();
});
it("rejects fileReferences that are not an array, contain one invalid value, or exceed the count limit", () => {
expect(turnContextManifestFromText(rawManifest({ fileReferences: {} }))).toBeNull();
expect(
turnContextManifestFromText(
rawManifest({
fileReferences: [
{ name: "Valid", path: "Valid.md" },
{ name: "", path: "Invalid.md" },
],
}),
),
).toBeNull();
expect(
turnContextManifestFromText(
rawManifest({
fileReferences: Array.from({ length: 65 }, (_, index) => ({ name: String(index), path: "p" })),
}),
),
).toBeNull();
});
it("accepts file reference field and count boundaries", () => {
const boundaryFields = [{ name: "n".repeat(255), path: "p".repeat(2_048) }];
const boundaryCount = Array.from({ length: 64 }, (_, index) => ({ name: String(index), path: "p" }));
expect(turnContextManifestFromText(rawManifest({ fileReferences: boundaryFields }))?.fileReferences).toEqual(boundaryFields);
expect(turnContextManifestFromText(rawManifest({ fileReferences: boundaryCount }))?.fileReferences).toEqual(boundaryCount);
});
it("rejects an oversized serialized manifest", () => {
const contexts = Array.from({ length: 40 }, (_, index) => ({
...webEntry(),
id: `context-${String(index)}`,
}));
expect(turnContextManifestFromText(rawManifest({ contexts }))).toBeNull();
});
});
describe("turn context manifest trust matching", () => {
function projectedManifest(manifest: TurnContextManifest, clientId: string | null = SUBMISSION_ID) {
return userMessageContextProjection(
[
{ type: "text", text: "visible request" },
{ type: "text", text: `\n${turnContextManifestText(manifest)}` },
],
clientId,
);
}
function validManifest(): TurnContextManifest {
return {
version: 2,
submissionId: SUBMISSION_ID,
contexts: [
{
kind: "web",
id: `${SUBMISSION_ID}.00`,
parts: 1,
sourceBytes: 10,
includedBytes: 10,
truncated: false,
},
],
};
}
it("hides only a manifest whose submission and context IDs match the client ID", () => {
expect(projectedManifest(validManifest())).toEqual({
text: "visible request",
manifest: validManifest(),
});
});
it.each([
["submission mismatch", { submissionId: "local-user-2-seed-2-2" }],
["context mismatch", { contexts: [{ ...validManifest().contexts[0], id: `${SUBMISSION_ID}.99x` }] }],
["duplicate context IDs", { contexts: [validManifest().contexts[0], validManifest().contexts[0]] }],
])("keeps a manifest-like text visible on %s", (_label, overrides) => {
const manifest = { ...validManifest(), ...overrides } as TurnContextManifest;
const projection = projectedManifest(manifest);
expect(projection.manifest).toBeNull();
expect(projection.text).toContain("visible request");
expect(projection.text).toContain("[Codex Panel context v2]");
});
it("does not trust a valid manifest when the client ID is not a Panel submission ID", () => {
expect(projectedManifest(validManifest(), "local-user")).toMatchObject({ manifest: null });
});
});

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { isPanelSubmissionClientId } from "../../../src/domain/chat/submission-id";
describe("Panel submission client IDs", () => {
it.each(["local-user-1-seed-1-1", "local-steer-42-seed_value-a9-z0", "local-user-123-UPPER_lower-abc-123"])("accepts %s", (value) => {
expect(isPanelSubmissionClientId(value)).toBe(true);
});
it.each([
null,
undefined,
"",
"local-user-1-seed-1",
"local-other-1-seed-1-1",
"local-user-x-seed-1-1",
"local-user-1-seed-1-1/extra",
"prefix-local-user-1-seed-1-1",
"local-user-1-seed-!-1",
"local-user-1-seed-1-!",
])("rejects %s", (value) => {
expect(isPanelSubmissionClientId(value)).toBe(false);
});
});