Harden task state and terminal rendering

This commit is contained in:
jb
2026-08-26 15:13:19 -04:00
parent 6826854b66
commit ae0ca47236
10 changed files with 452 additions and 48 deletions
+114
View File
@@ -172,6 +172,50 @@ test("restores timers and starts legacy in-progress snapshots at load time", ()
assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now);
});
test("rejects unsafe restored identifiers and oversized timing collections", () => {
const state = new TaskState(() => 0);
state.add([{ name: "Keep" }]);
const before = state.snapshot();
const task = { id: 1, name: "Task", status: "pending" as const, parentId: null, childrenIds: [] };
assert.throws(
() => state.restore({ tasks: [{ ...task, id: Number.MAX_SAFE_INTEGER + 1 }], nextId: Number.MAX_SAFE_INTEGER }),
/id must be a positive safe integer/,
);
assert.throws(
() => state.restore({ tasks: [task], nextId: Number.MAX_SAFE_INTEGER + 1 }),
/nextId must be a positive safe integer/,
);
assert.throws(
() =>
state.restore({
tasks: [task],
nextId: 2,
timings: Array.from({ length: 201 }, () => ({ taskId: 1, startedAt: 0, accumulatedMs: 0 })),
}),
/timings are limited to 200 entries/,
);
assert.throws(
() =>
state.restore({
tasks: [task],
nextId: 2,
timings: [{ taskId: 1, startedAt: 0, stoppedAt: 0, accumulatedMs: Number.MAX_SAFE_INTEGER }],
}),
/accumulatedMs must be an integer between/,
);
assert.deepEqual(state.snapshot(), before);
});
test("rejects additions when no safe generated task ID remains", () => {
const state = new TaskState(() => 0);
state.restore({ tasks: [], nextId: Number.MAX_SAFE_INTEGER });
const before = state.snapshot();
assert.throws(() => state.add([{ name: "Unsafe ID" }]), /no safe task IDs remain/);
assert.deepEqual(state.snapshot(), before);
});
test("rejects out-of-range persisted timestamps without replacing state", () => {
const state = new TaskState(() => 0);
state.add([{ name: "Keep" }]);
@@ -318,6 +362,76 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
});
test("rejects terminal controls and bidirectional overrides without changing state", () => {
const state = new TaskState();
state.add([{ name: "Safe", description: "Line one\nLine two" }]);
const before = state.snapshot();
for (const name of ["Escape\u001b[2J", "Tab\tname", "Arabic mark\u061cname", "Override\u202eevil"]) {
assert.throws(() => state.add([{ name }]), /unsafe control/);
assert.deepEqual(state.snapshot(), before);
}
assert.throws(() => state.update([{ id: 1, description: "Clipboard\u009d52;c;payload" }]), /unsafe control/);
assert.deepEqual(state.snapshot(), before);
});
test("enforces task name and description length limits by Unicode character", () => {
const state = new TaskState();
state.add([{ name: "😀".repeat(200), description: "d".repeat(4_000) }]);
const before = state.snapshot();
assert.throws(() => state.add([{ name: "n".repeat(201) }]), /name must be at most 200 characters/);
assert.throws(() => state.update([{ id: 1, description: "d".repeat(4_001) }]), /description must be at most 4000 characters/);
assert.deepEqual(state.snapshot(), before);
});
test("enforces per-mutation and per-session task limits atomically", () => {
const state = new TaskState();
const batch = Array.from({ length: 50 }, (_, index) => ({ name: `Task ${index + 1}` }));
assert.throws(() => state.add([...batch, { name: "Too many" }]), /at most 50 tasks/);
assert.equal(state.list().length, 0);
for (let index = 0; index < 4; index += 1) state.add(batch);
assert.equal(state.list().length, 200);
const before = state.snapshot();
assert.throws(() => state.add([{ name: "Session overflow" }]), /session is limited to 200 tasks/);
assert.deepEqual(state.snapshot(), before);
assert.throws(
() =>
new TaskState().restore({
tasks: Array.from({ length: 201 }, (_, index) => ({
id: index + 1,
name: `Restored ${index + 1}`,
status: "pending" as const,
parentId: null,
childrenIds: [],
})),
nextId: 202,
}),
/session is limited to 200 tasks/,
);
});
test("accepts eight task levels and rejects deeper trees atomically", () => {
const nested = (depth: number) => {
let task: { name: string; children?: Array<{ name: string; children?: unknown[] }> } = { name: `Level ${depth}` };
for (let level = depth - 1; level >= 1; level -= 1) {
task = { name: `Level ${level}`, children: [task] };
}
return task;
};
const state = new TaskState();
state.add([nested(8) as never]);
assert.equal(state.list().length, 8);
const before = state.snapshot();
assert.throws(() => state.add([nested(9) as never]), /depth must not exceed 8 levels/);
assert.deepEqual(state.snapshot(), before);
});
test("supports nullable descriptions and reports invalid children as validation errors", () => {
const state = new TaskState();
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
+66
View File
@@ -51,6 +51,72 @@ test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => {
assert.ok(!/"\$ref":"(?!\#)/.test(serialized), "no bare $ref values remain");
});
test("task schemas publish text and batch limits", () => {
const { tools } = registeredTools();
const setParams = tools.get("set_tasks").parameters;
const addTask = setParams.$defs.AddTask;
const updateTask = tools.get("update_task").parameters.properties.tasks.items;
assert.equal(setParams.properties.tasks.maxItems, 50);
assert.equal(addTask.properties.children.maxItems, 50);
assert.equal(addTask.properties.name.maxLength, 200);
assert.match(addTask.properties.name.pattern, /061C/i);
assert.equal(addTask.properties.description.anyOf[0].maxLength, 4_000);
assert.match(addTask.properties.description.anyOf[0].pattern, /061C/i);
assert.equal(tools.get("update_task").parameters.properties.tasks.maxItems, 50);
assert.equal(updateTask.properties.name.anyOf[0].maxLength, 200);
assert.equal(updateTask.properties.description.anyOf[0].maxLength, 4_000);
assert.equal(tools.get("rm_task").parameters.properties.ids.maxItems, 50);
assert.equal(tools.get("get_task").parameters.properties.ids.maxItems, 50);
const setTool = tools.get("set_tasks");
const validUnicode = setTool.prepareArguments({
mode: "append",
tasks: [{ name: "😀".repeat(200), description: "line one\nline two" }],
});
assert.doesNotThrow(() =>
validateToolArguments(setTool, { name: setTool.name, arguments: validUnicode } as never),
);
const unsafeText = setTool.prepareArguments({ mode: "append", tasks: [{ name: "unsafe\u061cname" }] });
assert.throws(
() => validateToolArguments(setTool, { name: setTool.name, arguments: unsafeText } as never),
/validation failed/i,
);
});
test("set_tasks preparation bounds hostile recursive input before validation", () => {
const { tools } = registeredTools();
const setTool = tools.get("set_tasks");
let nested: Record<string, unknown> = { name: "Leaf" };
for (let depth = 0; depth < 10_000; depth += 1) nested = { name: `Level ${depth}`, children: [nested] };
const prepared = setTool.prepareArguments({ mode: "append", tasks: [nested] });
assert.throws(
() => validateToolArguments(setTool, { name: setTool.name, arguments: prepared } as never),
/validation failed/i,
);
const oversized = setTool.prepareArguments({
mode: "append",
tasks: Array.from({ length: 1_000 }, (_, index) => ({ name: `Task ${index}` })),
});
assert.equal(oversized.tasks.length, 51);
assert.throws(
() => validateToolArguments(setTool, { name: setTool.name, arguments: oversized } as never),
/validation failed/i,
);
const updateTool = tools.get("update_task");
const oversizedUpdate = updateTool.prepareArguments({
tasks: Array.from({ length: 1_000 }, (_, index) => ({ id: index + 1 })),
});
assert.equal(oversizedUpdate.tasks.length, 51);
assert.throws(
() => validateToolArguments(updateTool, { name: updateTool.name, arguments: oversizedUpdate } as never),
/validation failed/i,
);
});
test("strict task schemas normalize legacy optional arguments", () => {
const { tools } = registeredTools();
const setTool = tools.get("set_tasks");
+12
View File
@@ -109,6 +109,18 @@ test("collapses completed widget subtrees while keeping list trees expanded", ()
assert.ok(reopened.some((line) => line.includes("Done two")));
});
test("caps the widget at twelve task rows with an overflow summary", () => {
const state = new TaskState(() => 0);
state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` })));
const lines = renderWidgetTree(state.list(), plainTheme as never, 60).map(stripAnsi);
assert.equal(lines.length, 13);
assert.match(lines[0] ?? "", /Task 1/);
assert.match(lines[11] ?? "", /Task 12/);
assert.equal(lines[12], " … 8 more tasks");
assert.ok(lines.every((line) => visibleWidth(line) <= 60));
});
test("hides a completed widget summary before squeezing the task name", () => {
const state = new TaskState(() => 0);
state.add([{ name: "A long parent task name", children: [{ name: "Done", status: "done" }] }]);