fix(runner): align editable draft bounds with Unicode schema lengths

Count initialText Unicode code points consistently in TypeScript, Zod, JSON Schema and Rust. Keep submitted-answer UTF-16 limits unchanged, with mixed BMP and astral boundary tests inside the wire byte cap.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 12:39:25 -05:00
1 parent c2b0c804cb
commit 4f9506072c
13 files changed
+68 -15

No files matched your search

@@ -107,7 +107,7 @@ Codex answer objects, OpenCode answer arrays, and ACP typed content exist only
inside their adapters; `origin` may retain the provider method and adapter name
for diagnostics but never provider response data.
Text-mode questions may include `initialText` (at most 100,000 UTF-16 code units,
Text-mode questions may include `initialText` (at most 100,000 Unicode code points,
within the existing 196 KiB question-set bound). It supplies an editable starting
draft, including empty text and whitespace. ACP plain-string `default` values map
without trimming; numeric defaults become editable numeric text. Select questions
@@ -116,7 +116,10 @@ ahead of provider defaults. Initial text never submits or resolves a request:
the operator must explicitly submit, and all existing response validation still
applies. Canonical text answers retain submitted whitespace and literal escape
sequences; legacy and select custom answers retain their existing normalization.
Required blank answers remain invalid. Redaction, company scope and durable
JSON Schema, TypeScript and Rust count draft Unicode code points alike. Existing
submitted-answer limits still count UTF-16 code units; a draft can require editing
before it satisfies those limits or field-specific constraints. Required blank
answers remain invalid. Redaction, company scope and durable
replay rules remain in force.
Question and option order is significant, while answers are keyed by stable
@@ -85,7 +85,7 @@
{
"path": "schemas/question-set.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json",
"sha256": "85f698e13343319f140e1afa5af16f41abfcba7baae182baffaf45215a31e7a4"
"sha256": "81dc76610e40665dbc4a42e4029296dffc597ba0bbee1fe92a130dffc0c07cba"
},
{
"path": "schemas/request.schema.json",
@@ -60,7 +60,7 @@
"helpText": { "type": "string", "maxLength": 4000 },
"required": { "type": "boolean" },
"answerMode": { "enum": ["single_select", "multi_select", "text"] },
"initialText": { "type": "string", "maxLength": 100000 },
"initialText": { "type": "string", "maxLength": 100000, "description": "Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds." },
"options": {
"type": "array",
"maxItems": 128,
@@ -620,10 +620,10 @@ pub(crate) fn validate_question_set(value: &Value) -> Result<(), LocalRunnerErro
if question
.get("initialText")
.and_then(Value::as_str)
.is_some_and(|text| text.encode_utf16().count() > 100_000)
.is_some_and(|text| text.chars().count() > 100_000)
{
return Err(LocalRunnerError::invalid(
"ACPX initial text exceeds its UTF-16 bound",
"ACPX initial text exceeds its Unicode code-point bound",
));
}
let mut option_ids = BTreeSet::new();
@@ -275,10 +275,10 @@ fn validate_persisted_question(question: &Value) -> Result<(), LocalRunnerError>
if question
.get("initialText")
.and_then(Value::as_str)
.is_some_and(|text| javascript_string_length(text) > MAX_QUESTION_ANSWER_CODE_UNITS)
.is_some_and(|text| text.chars().count() > 100_000)
{
return Err(LocalRunnerError::invalid(
"persisted initial text exceeds its UTF-16 bound",
"persisted initial text exceeds its Unicode code-point bound",
));
}
let question_id = question
@@ -452,6 +452,19 @@ fn input_initial_text_survives_wire_admission_with_existing_redaction() {
}
_ => panic!("input request expected"),
}
for code_points in [100_000, 100_001] {
let initial = format!("{}😀", "a".repeat(code_points - 1));
let payload = input(json!(initial), "text");
assert!(serde_json::to_vec(&payload).unwrap().len() < 196 * 1024);
let result = decode_acpx_event(
&scope,
&event(
GeneratedAcpxSidecarEventType::RuntimeInputRequested,
payload,
),
);
assert_eq!(result.is_ok(), code_points == 100_000);
}
for invalid in [
input(json!(1), "text"),
input(json!("a".repeat(100_001)), "text"),
@@ -330,6 +330,9 @@ fn initial_text_is_never_an_implicit_answer_or_validation_bypass() {
let mut invalid = valid_response();
invalid["answers"]["notes"] = json!({"text":"bad"});
assert!(validate_question_response(&questions, &invalid).is_err());
questions["questions"][2]["initialText"] = json!("😀".repeat(50_001));
questions["questions"][2]["initialText"] = json!(format!("{}😀", "a".repeat(99_999)));
assert!(serde_json::to_vec(&questions).unwrap().len() < 196 * 1024);
validate_question_response(&questions, &valid_response()).unwrap();
questions["questions"][2]["initialText"] = json!(format!("{}😀", "a".repeat(100_000)));
assert!(validate_question_response(&questions, &valid_response()).is_err());
}
@@ -1,3 +1,5 @@
import Ajv2020 from "ajv/dist/2020.js";
import { questionSetSchema } from "../protocol/generated/schema-bundle.js";
import { describe, expect, it } from "vitest";
import {
@@ -57,7 +59,7 @@ describe("Paperclip question-set contract", () => {
expect(parsePaperclipQuestionSet(input)).toEqual(input);
expect(() => parsePaperclipQuestionResponse(input, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers: {} })).toThrow(/required/);
}
for (const initialText of [null, 1, "a".repeat(100_001), "😀".repeat(50_001)]) {
for (const initialText of [null, 1, "a".repeat(100_001), "a".repeat(100_000) + "😀"]) {
expect(() => parsePaperclipQuestionSet({ ...questionSet, questions: [{ ...questionSet.questions[1], initialText }] })).toThrow(/initialText/);
}
expect(() => parsePaperclipQuestionSet({ ...questionSet, questions: [{ ...questionSet.questions[0], initialText: "staging" }] })).toThrow(/only text/);
@@ -65,6 +67,23 @@ describe("Paperclip question-set contract", () => {
expect(() => parsePaperclipQuestionResponse(input, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers: { replicas: { text: "21" } } })).toThrow(/at most 20/);
});
it("matches the published Unicode code-point draft limit within the wire byte cap", () => {
const validate = new Ajv2020({ strict: true, strictRequired: false }).compile(questionSetSchema);
for (const codePoints of [100_000, 100_001]) {
const initialText = "a".repeat(codePoints - 1) + "😀";
const input = { schema: PAPERCLIP_QUESTION_SET_SCHEMA, questions: [{ id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText }] };
expect(Buffer.byteLength(JSON.stringify(input))).toBeLessThan(196 * 1024);
expect(initialText.length).toBe(codePoints + 1);
expect(validate(input)).toBe(codePoints === 100_000);
if (codePoints === 100_000) {
expect(parsePaperclipQuestionSet(input)).toEqual(input);
expect(() => parsePaperclipQuestionResponse(input, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers: { draft: { text: initialText } } })).toThrow();
} else {
expect(() => parsePaperclipQuestionSet(input)).toThrow(/Unicode code points/);
}
}
});
it("rejects missing, unknown, and provider-shaped answers", () => {
expect(() => parsePaperclipQuestionResponse(questionSet, {
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
@@ -184,7 +184,14 @@ export function parsePaperclipQuestionSet(value: unknown): PaperclipQuestionSet
if (answerMode === "text" && options !== undefined && options.length > 0) {
throw new PaperclipQuestionValidationError(`${path}/options`, "text questions cannot define options");
}
const initialText = optionalText(question.initialText, `${path}/initialText`, 100_000);
const initialText = question.initialText;
if (initialText !== undefined && (
typeof initialText !== "string"
|| initialText.length > 200_000
|| Array.from(initialText).length > 100_000
)) {
throw new PaperclipQuestionValidationError(`${path}/initialText`, "must be a string of at most 100000 Unicode code points");
}
if (initialText !== undefined && answerMode !== "text") {
throw new PaperclipQuestionValidationError(`${path}/initialText`, "only text questions can define initial text");
}
@@ -2845,7 +2845,8 @@ export const questionSetSchema = {
},
"initialText": {
"type": "string",
"maxLength": 100000
"maxLength": 100000,
"description": "Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds."
},
"options": {
"type": "array",
@@ -4,4 +4,4 @@
import equalModule from "ajv/dist/runtime/equal.js";
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-1 Line truncated
"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-1 Line truncated
+8 -1
View File
@@ -643,8 +643,15 @@ describe("persisted canonical question initial text", () => {
expect(paperclipQuestionSetPayloadSchema.parse(input({ initialText }))).toEqual(input({ initialText }));
}
});
it("counts mixed BMP and astral draft text like JSON Schema", () => {
for (const codePoints of [100_000, 100_001]) {
const initialText = "a".repeat(codePoints - 1) + "😀";
expect(Buffer.byteLength(JSON.stringify(input({ initialText })))).toBeLessThan(196 * 1024);
expect(paperclipQuestionSetPayloadSchema.safeParse(input({ initialText })).success).toBe(codePoints === 100_000);
}
});
it("rejects non-text modes and unbounded or nonstring defaults", () => {
for (const initialText of [null, 1, "x".repeat(100_001), "😀".repeat(50_001)]) {
for (const initialText of [null, 1, "x".repeat(100_001), "a".repeat(100_000) + "😀"]) {
expect(paperclipQuestionSetPayloadSchema.safeParse(input({ initialText })).success).toBe(false);
}
expect(paperclipQuestionSetPayloadSchema.safeParse(input({ answerMode: "single_select", options: [{ id: "a", label: "A" }], initialText: "a" })).success).toBe(false);
+1 -1
View File
@@ -1251,7 +1251,7 @@ const paperclipQuestionSchema = z
helpText: z.string().max(4000).optional(),
required: z.boolean(),
answerMode: z.enum(["single_select", "multi_select", "text"]),
initialText: z.string().max(100000).optional(),
initialText: z.string().refine(value => value.length <= 200000 && Array.from(value).length <= 100000, "initial text exceeds 100000 Unicode code points").optional(),
options: z.array(paperclipQuestionOptionSchema).max(128).optional(),
customAnswer: z
.object({