diff --git a/bun.lock b/bun.lock index 92e3a5895a1..169c7f5bf19 100644 --- a/bun.lock +++ b/bun.lock @@ -113,7 +113,7 @@ "ws": "^8.18.3", "xxhash-wasm": "^1.1.0", "yaml": "^2.8.2", - "zod": "^4.2.0", + "zod": "^4.6.5", "zod-to-json-schema": "^3.24.6", }, "devDependencies": { @@ -3705,7 +3705,7 @@ "zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], @@ -3843,8 +3843,6 @@ "@modelcontextprotocol/server/@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.2.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw=="], - "@modelcontextprotocol/server/zod": ["zod@4.6.1", "", {}, "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q=="], - "@orpc/shared/type-fest": ["type-fest@5.9.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw=="], "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], diff --git a/package.json b/package.json index fa923afb1b4..96ae9096d27 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,7 @@ "ws": "^8.18.3", "xxhash-wasm": "^1.1.0", "yaml": "^2.8.2", - "zod": "^4.2.0", + "zod": "^4.6.5", "zod-to-json-schema": "^3.24.6" }, "devDependencies": { diff --git a/src/common/orpc/schemas/workflow.test.ts b/src/common/orpc/schemas/workflow.test.ts index aa321c69225..88f5ecc831d 100644 --- a/src/common/orpc/schemas/workflow.test.ts +++ b/src/common/orpc/schemas/workflow.test.ts @@ -226,6 +226,18 @@ describe("workflow domain schemas", () => { expect(record.evaluation).toEqual(admission); expect(record.taskId).toBeUndefined(); + // A minute-precision deadline (zod 4.4 accepted it) stays readable in persisted records. + const minuteDeadline = { ...admission, attemptDeadlineAt: "2026-05-29T00:01Z" }; + expect( + WorkflowStepRecordSchema.safeParse({ + stepId: "screen-issue", + inputHash: "sha256:screen-issue", + status: "started", + startedAt: "2026-05-29T00:00Z", + evaluation: minuteDeadline, + }).success + ).toBe(true); + // Agent/patch records never carry the field and must keep parsing. const agentRecord = WorkflowStepRecordSchema.parse({ stepId: "reserve-child", diff --git a/src/common/orpc/schemas/workflow.ts b/src/common/orpc/schemas/workflow.ts index 58702b2611b..2a5eb15c7b6 100644 --- a/src/common/orpc/schemas/workflow.ts +++ b/src/common/orpc/schemas/workflow.ts @@ -37,7 +37,15 @@ export const WorkflowRunStatusSchema = z.enum([ "failed", ]); -const IsoDateTimeSchema = z.string().datetime({ offset: true }); +// zod >= 4.5 rejects minute-precision datetimes with a zone ("2026-05-29T00:01Z"), which zod +// 4.4 accepted. Writers use toISOString (always with seconds), but persisted run.json and +// journal records from before or from other writers must stay readable: an unreadable run.json +// makes createRunIfAbsent treat the run as half-created and delete it. This is zod's +// documented union for accepting both precisions. +const IsoDateTimeSchema = z.union([ + z.iso.datetime({ offset: true }), + z.iso.datetime({ offset: true, precision: -1 }), +]); export const JsonValueSchema: z.ZodType = z.lazy(() => z.union([ z.string(), diff --git a/src/common/types/evaluation.ts b/src/common/types/evaluation.ts index 1c22e006ca3..4a7f059df7b 100644 --- a/src/common/types/evaluation.ts +++ b/src/common/types/evaluation.ts @@ -237,7 +237,12 @@ export const EvaluationAdmissionSchema = z.object({ configFingerprint: z.string(), }), timeoutMs: z.number().int().positive(), - attemptDeadlineAt: z.string().datetime({ offset: true }), + // Persisted in workflow step records, so it accepts the same minute-precision form as the + // other workflow timestamps (IsoDateTimeSchema in orpc/schemas/workflow.ts). + attemptDeadlineAt: z.union([ + z.iso.datetime({ offset: true }), + z.iso.datetime({ offset: true, precision: -1 }), + ]), providerOptions: EvaluationProviderOptionsSchema.optional(), stateSha256: z.string(), stateBytes: z.number().int().nonnegative(), diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 8a2dbc0cb6e..1c68ef924f5 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -2625,6 +2625,42 @@ describe("Config", () => { review: { defaultBaseByProject: { "/repo/a": "main" } }, }); }); + + // zod >= 4.5 strips `__proto__` keys in object and record parsers. JSON.parse makes + // such a key an own property, so a hand-edited config.json can carry one. Lenient + // loading must still drop only the bad entries and must not adopt the key as a + // prototype (no inherited project defaults, no polluted Object.prototype). + it("drops __proto__ keys and invalid entries from persisted preference records", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + `{ + "projects": [], + "userPreferences": { + "__proto__": { "polluted": true }, + "ai": { + "projectDefaults": { + "__proto__": { "agentId": "plan" }, + "/repo": { "agentId": "exec" } + }, + "autoCompactionThresholdByModel": { "__proto__": 70, "openai:gpt-4.1": 75, "bad": 101 } + } + } + }` + ); + + const preferences = config.loadConfigOrDefault().userPreferences; + + expect(preferences).toEqual({ + ai: { + projectDefaults: { "/repo": { agentId: "exec" } }, + autoCompactionThresholdByModel: { "openai:gpt-4.1": 75 }, + }, + }); + const projectDefaults = preferences?.ai?.projectDefaults; + expect(projectDefaults && Object.getPrototypeOf(projectDefaults)).toBe(Object.prototype); + expect(Object.keys(projectDefaults ?? {})).toEqual(["/repo"]); + expect(Object.prototype).not.toHaveProperty("polluted"); + }); }); it.each([ diff --git a/src/node/services/historyMessageEvidence.test.ts b/src/node/services/historyMessageEvidence.test.ts index 5a3dbd2da8e..c89c98d3cc1 100644 --- a/src/node/services/historyMessageEvidence.test.ts +++ b/src/node/services/historyMessageEvidence.test.ts @@ -7,6 +7,9 @@ import { isReadableHistoryMessage } from "./historyScanner"; import { normalizeLegacyMuxMetadata } from "@/node/utils/messages/legacy"; import { scanHistoryRows } from "./historyRowScanner.testHarness"; import { createHistoryMessageEvidence } from "./historyMessageEvidence"; +import { WorkflowScriptDescriptorSchema } from "@/common/orpc/schemas/workflow"; + +const DESCRIPTION_MAX = WorkflowScriptDescriptorSchema.shape.description.maxLength!; const date = "2026-09-11T12:00:00Z"; const run = () => ({ @@ -261,7 +264,8 @@ describe("streamed history message evidence", () => { expect(result.id?.matchesExpected).toBe(true); expect(result.matchesNonce).toBe(true); expect(result.id?.length).toBe(huge.length); - expect(result.id!.prefix.length).toBeLessThan(2048); + // The prefix stays bounded: one code point past the largest bound, in UTF-16 units. + expect(result.id!.prefix.length).toBeLessThanOrEqual(2 * (DESCRIPTION_MAX + 1)); await differential([ JSON.stringify( message([tool({ ...run(), workflow: { ...run().workflow, description: huge } })]) @@ -269,6 +273,37 @@ describe("streamed history message evidence", () => { ]); }); + // zod >= 4.5 counts string bounds in code points, so an astral character counts once even + // though it takes two UTF-16 units. The streamed evidence must agree with the real parse at + // the bound for every mix of one-unit and two-unit characters. + it("matches the real parse at the description bound in code points", async () => { + const emoji = "\u{1F600}"; + const withDescription = (description: string) => + JSON.stringify(message([tool({ ...run(), workflow: { ...run().workflow, description } })])); + const descriptions = [ + "x".repeat(DESCRIPTION_MAX), + "x".repeat(DESCRIPTION_MAX + 1), + emoji.repeat(DESCRIPTION_MAX), + emoji.repeat(DESCRIPTION_MAX + 1), + "x" + emoji.repeat(DESCRIPTION_MAX - 1), + "x" + emoji.repeat(DESCRIPTION_MAX), + emoji.repeat(DESCRIPTION_MAX - 2) + "xy", + emoji.repeat(DESCRIPTION_MAX - 1) + "xy", + ]; + const results = await inspect(descriptions.map(withDescription)); + expect(results.map((result) => result.readable)).toEqual([ + true, + false, + true, + false, + true, + false, + true, + false, + ]); + await differential(descriptions.map(withDescription)); + }); + it("matches schema field mutations without retaining whole workflow records", async () => { const rich = { ...run(), diff --git a/src/node/services/historyMessageEvidence.ts b/src/node/services/historyMessageEvidence.ts index 1e8ade2418b..b83ee4920bf 100644 --- a/src/node/services/historyMessageEvidence.ts +++ b/src/node/services/historyMessageEvidence.ts @@ -11,7 +11,9 @@ import type { HistoryRowDescriptor, HistoryRowToken } from "./historyRowScanner" import { createHistoryStringEvidence, createHistoryNumberEvidence } from "./historyScalarEvidence"; const INVALID = Symbol("invalid history projection"); -const STRING_PREFIX = WorkflowScriptDescriptorSchema.shape.description.maxLength! + 1; +// zod >= 4.5 bounds string length in code points. A code point takes at most two UTF-16 +// units, so this many units always hold one code point past the largest bound. +const STRING_PREFIX = 2 * (WorkflowScriptDescriptorSchema.shape.description.maxLength! + 1); type StringFacts = ReturnType["finish"]>; interface Value { value: unknown; @@ -187,8 +189,9 @@ export function createHistoryMessageEvidence( const key = frame.key!.prefix; if (shape && Object.hasOwn(shape, key) && key.length === frame.key!.length) frame.fields.set(key, item); - else if (strict.has(frame.context) && key !== "__proto__") - frame.fields.set("", { value: null }); + // zod >= 4.5 `.strict()` reports an own `__proto__` key (JSON.parse makes one) as + // unrecognized, so it fails the row like any other extra key. + else if (strict.has(frame.context)) frame.fields.set("", { value: null }); } }; return { diff --git a/src/node/services/ptc/typeGenerator.ts b/src/node/services/ptc/typeGenerator.ts index 8841335e353..6848a93df88 100644 --- a/src/node/services/ptc/typeGenerator.ts +++ b/src/node/services/ptc/typeGenerator.ts @@ -208,8 +208,10 @@ async function getResultTypeString(toolName: string): Promise { const schema = getToolResultSchema(toolName); if (!schema) return null; - // Convert Zod → JSON Schema → TypeScript - const jsonSchema = z.toJSONSchema(schema); + // Convert Zod → JSON Schema → TypeScript. json-schema-to-typescript understands + // draft-7 tuples (`items: [...]`) but not draft-2020-12 `prefixItems`, which + // zod >= 4.5 emits by default; without draft-7, tuple results type as `never[]`. + const jsonSchema = z.toJSONSchema(schema, { target: "draft-7" }); const tsOutput = await compile( jsonSchema as Parameters[0], `${pascalCase(toolName)}Result`, diff --git a/src/node/services/ptc/typeValidator.test.ts b/src/node/services/ptc/typeValidator.test.ts index 3bbf6f9a488..df347ced229 100644 --- a/src/node/services/ptc/typeValidator.test.ts +++ b/src/node/services/ptc/typeValidator.test.ts @@ -7,6 +7,7 @@ import type { Tool } from "ai"; import { DisposableTempDir } from "@/node/services/tempDir"; import { findBundledTypeScriptLibDir, validateTypes } from "./typeValidator"; import { generateXumTypes } from "./typeGenerator"; +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; /** * Create a mock tool with the given schema. @@ -990,3 +991,35 @@ mux.file_read({ path: "wrong" });`, expect(result.valid).toBe(true); }); }); + +// zod >= 4.5 emits closed tuples as `prefixItems` + `items: false` in its default +// draft-2020-12 output. json-schema-to-typescript reads only draft-7 tuples, so the +// attach_file result `value` (a tuple union) became `never[]` and valid guest code +// that inspects the parts failed to typecheck. +describe("attach_file result types (real tool definition)", () => { + test("guest code can read the parts of a content result", async () => { + const attachFile = TOOL_DEFINITIONS.attach_file; + const types = await generateXumTypes({ + attach_file: { + description: attachFile.description, + inputSchema: attachFile.schema, + execute: () => Promise.resolve({ success: true }), + } as unknown as Tool, + }); + const result = validateTypes( + ` + const r = xum.attach_file({ path: "/tmp/a.png" }); + const texts = []; + if ("value" in r && r.type === "content") { + for (const part of r.value) { + if (part.type === "text") texts.push(part.text); + } + } + return texts; + `, + types + ); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.valid).toBe(true); + }); +}); diff --git a/src/node/services/workflows/WorkflowRunStore.test.ts b/src/node/services/workflows/WorkflowRunStore.test.ts index c8a9efe93ce..b42b96dfa6b 100644 --- a/src/node/services/workflows/WorkflowRunStore.test.ts +++ b/src/node/services/workflows/WorkflowRunStore.test.ts @@ -352,6 +352,86 @@ describe("WorkflowRunStore", () => { expect(completed?.result?.structuredOutput).toEqual({ ok: true }); }); + // zod >= 4.5 rejects minute-precision datetimes with a zone ("...T00:01Z") unless the schema + // allows them, and zod 4.4 accepted them. Xum writes toISOString (always with seconds), but a + // persisted record from a hand edit or another writer must stay readable: an unreadable + // run.json also makes createRunIfAbsent treat the run as half-created and delete it. + test("keeps minute-precision journal lines readable", async () => { + using tmp = new DisposableTempDir("workflow-runs-minute-precision"); + const store = await createStore(tmp.path); + const runDir = path.join(tmp.path, "workflows", "wfr_123"); + + await store.appendEvent("wfr_123", { + sequence: 1, + type: "phase", + at: new Date("2026-05-29T00:00:01Z").toISOString(), + name: "scope", + }); + await fs.appendFile( + path.join(runDir, "events.jsonl"), + [ + { sequence: 2, type: "log", at: "2026-05-29T00:01Z", message: "minute precision" }, + { sequence: 3, type: "log", at: "2026-05-29T00:02+02:00", message: "offset" }, + ] + .map((event) => `${JSON.stringify(event)}\n`) + .join("") + ); + await fs.appendFile( + path.join(runDir, "steps.jsonl"), + `${JSON.stringify({ + stepId: "minute-step", + inputHash: "input:2", + status: "started", + startedAt: "2026-05-29T00:01Z", + })}\n` + ); + + const run = await store.getRun("wfr_123"); + + expect(run.events.map((event) => event.sequence)).toEqual([1, 2, 3]); + expect(run.steps.map((step) => step.startedAt)).toEqual(["2026-05-29T00:01Z"]); + expect(run.updatedAt).toBe("2026-05-29T00:02+02:00"); + }); + + test("keeps a run.json with minute-precision timestamps readable and in place", async () => { + using tmp = new DisposableTempDir("workflow-runs-minute-precision-run"); + const store = new WorkflowRunStore({ sessionDir: tmp.path }); + const input = { + id: "wfr_child_minute", + workspaceId: "workspace-1", + workflow: definition, + source: source, + args: { topic: "nested" }, + parentWorkflow: { runId: "wfr_parent", stepId: "child", inputHash: "hash:child", depth: 0 }, + now: "2026-05-29T00:00:00.000Z", + }; + await store.createRunIfAbsent(input); + await store.appendEvent("wfr_child_minute", { + sequence: 1, + type: "log", + at: "2026-05-29T00:00:01.000Z", + message: "kept", + }); + const runFile = path.join(tmp.path, "workflows", "wfr_child_minute", "run.json"); + const raw = JSON.parse(await fs.readFile(runFile, "utf-8")) as Record; + await fs.writeFile( + runFile, + JSON.stringify({ ...raw, createdAt: "2026-05-29T00:00Z", updatedAt: "2026-05-29T00:01Z" }) + ); + + await expect(store.getRun("wfr_child_minute")).resolves.toMatchObject({ + createdAt: "2026-05-29T00:00Z", + }); + await expect(store.getRunStatusSnapshot("wfr_child_minute")).resolves.toMatchObject({ + id: "wfr_child_minute", + }); + const again = await store.createRunIfAbsent(input); + expect(again.events.map((event) => event.type === "log" && event.message)).toEqual(["kept"]); + await expect( + fs.readFile(path.join(tmp.path, "workflows", "wfr_child_minute", "events.jsonl"), "utf-8") + ).resolves.toContain('"kept"'); + }); + test("rejects duplicate or out-of-order event sequence numbers", async () => { using tmp = new DisposableTempDir("workflow-runs"); const store = await createStore(tmp.path);