',
+ });
+ const text = dec.decode(
+ assembleMessage(headers, content, enc.encode("SIG")),
+ );
+ expect(text).toContain('Subject: Re: the "urgent" request\r\n');
+ expect(text).toContain('From: "Doe, Jane" \r\n');
+ });
});
// ---------------------------------------------------------------------------
@@ -332,11 +503,69 @@ describe("parseHeaderSection", () => {
expect(dec.decode(raw.slice(bodyOffset))).toBe("Body here");
});
- test("parses LF-terminated headers", () => {
+ test("refuses an LF-terminated message", () => {
+ // RFC 5321 section 2.3.8 forbids recognizing any character or sequence but
+ // CRLF as a line terminator, and section 4.1.1.4 refuses the lone-LF ending
+ // by name. Folding the breaks instead would leave one field whose value
+ // swallows every later field and the body.
const raw = enc.encode("From: alice@test\nTo: bob@test\n\nBody");
+ expect(() => parseHeaderSection(raw)).toThrow(
+ /must break its lines with CRLF/,
+ );
+ });
+
+ test("refuses a CR-terminated message", () => {
+ const raw = enc.encode("From: alice@test\rTo: bob@test\r\rBody");
+ expect(() => parseHeaderSection(raw)).toThrow(
+ /must break its lines with CRLF/,
+ );
+ });
+
+ test("refuses an LF-only header section that a CRLF CRLF terminates", () => {
+ // The section's own line breaks decide whether it conforms, not the flavour
+ // of the blank line that ends it. Folding these instead leaves one field
+ // whose value swallows `Interchange-Type` and `Subject`, so a sender
+ // suppresses both fields by writing the section with bare LFs.
+ const raw = enc.encode(
+ "From: alice@x\nInterchange-Type: conversation.message\n" +
+ "Subject: Hi\r\n\r\nBody",
+ );
+ expect(() => parseHeaderSection(raw)).toThrow(
+ /must break its lines with CRLF/,
+ );
+ });
+
+ test("refuses an LF-only header section when the body carries a CRLF CRLF", () => {
+ // Searching the whole message for the separator finds the one in the body
+ // and reads the header section from there, which both suppresses every
+ // field after the first and truncates the body to what followed it.
+ const raw = enc.encode(
+ "From: alice@x\nInterchange-Type: conversation.message\n" +
+ "Subject: Hi\n\nBody\r\n\r\ntail\n",
+ );
+ expect(() => parseHeaderSection(raw)).toThrow(
+ /must break its lines with CRLF/,
+ );
+ });
+
+ test("refuses an LF LF separator after a CRLF-only section", () => {
+ // The blank line is a pair of line breaks of its own, so honouring a bare
+ // pair lets a sender who controls one field body end the section early and
+ // strip the fields after it.
+ const raw = enc.encode(
+ "From: alice@x\r\nSubject: Hi\n\n" +
+ "Interchange-Type: conversation.message\r\n\r\nBody",
+ );
+ expect(() => parseHeaderSection(raw)).toThrow(
+ /must break its lines with CRLF/,
+ );
+ });
+
+ test("takes the first blank line when the body carries another", () => {
+ const raw = enc.encode("Subject: Hi\r\n\r\nBody\r\n\r\nmore");
const { headers, bodyOffset } = parseHeaderSection(raw);
- expect(headers.get("from")).toBe("alice@test");
- expect(dec.decode(raw.slice(bodyOffset))).toBe("Body");
+ expect(headers.get("subject")).toBe("Hi");
+ expect(dec.decode(raw.slice(bodyOffset))).toBe("Body\r\n\r\nmore");
});
test("unfolds continuation lines", () => {
@@ -397,6 +626,46 @@ describe("parseHeaderSection", () => {
expect(bodyOffset).toBe(4);
expect(headers.size).toBe(0);
});
+
+ // A bare CR and a bare LF are external input that no field body may carry:
+ // RFC 5322 section 2.2 admits neither inside a field body, and section 2.3
+ // requires the two to occur only together as CRLF. A sender controls every one
+ // of these fields, and each row hides the same second field behind one bare
+ // character. Neither reading of that character is safe -- as a terminator it
+ // resolves a field from a value the sender smuggled inside one it controls,
+ // and folded to a space it suppresses whatever field followed it -- so the
+ // section is refused for every row.
+ const bareBreaks = ["\r", "\n"];
+ const bareBreakHeaders: string[] = bareBreaks.flatMap((brk) => [
+ `Subject: hello${brk}Bcc: attacker@evil.test`,
+ `From: alice@example.com${brk}Bcc: attacker@evil.test`,
+ `Message-ID: ${brk}Bcc: attacker@evil.test`,
+ `Interchange-Correlation-ID: corr-1${brk}Bcc: attacker@evil.test`,
+ `References: ${brk}Bcc: attacker@evil.test`,
+ `Subject: benign${brk}Interchange-Agent-Id: victim`,
+ ]);
+
+ test("refuses a bare carriage return or line feed in a field body", () => {
+ for (const header of bareBreakHeaders) {
+ expect(() =>
+ parseHeaderSection(
+ enc.encode(`${header}\r\nTo: bob@example.com\r\n\r\nbody`),
+ ),
+ ).toThrow(/must break its lines with CRLF/);
+ }
+ });
+
+ test("leaves CRLF folding and line termination alone", () => {
+ // The fold targets a CR that is not part of a CRLF, so a real terminator
+ // and a real continuation line still mean what they meant.
+ const { headers } = parseHeaderSection(
+ enc.encode(
+ "References: \r\n \r\nSubject: kept\r\n\r\nBody",
+ ),
+ );
+ expect(headers.get("references")).toBe(" ");
+ expect(headers.get("subject")).toBe("kept");
+ });
});
// ---------------------------------------------------------------------------
@@ -444,9 +713,16 @@ describe("parseMultipart", () => {
expect(dec.decode(defined(parts[1]))).toContain("Part two
");
});
- test("handles LF-only line endings", () => {
+ test("splits parts whose delimiter lines are LF-terminated", () => {
+ // The boundary scan tolerates an LF-terminated delimiter line. Each part's
+ // own fields are CRLF-terminated, because CRLF is the only field terminator
+ // the header section parse recognizes (RFC 5322 section 2.3); this fixture
+ // keeps the two questions apart rather than asserting both tolerances from
+ // one input.
const body = enc.encode(
- "--boundary\nContent-Type: text/plain\n\nPart one\n--boundary\nContent-Type: text/html\n\nPart two
\n--boundary--\n",
+ "--boundary\nContent-Type: text/plain\r\n\r\nPart one\n" +
+ "--boundary\nContent-Type: text/html\r\n\r\nPart two
\n" +
+ "--boundary--\n",
);
const parts = parseMultipart(body, "boundary");
expect(parts).toHaveLength(2);
@@ -1052,6 +1328,7 @@ describe("conversation attachments round-trip", () => {
const got = defined(extracted[i]);
expect(got.name).toBe(orig.name);
expect(got.contentType).toBe(orig.contentType);
+ expect(got.part).toBe(`1.${i + 2}`);
expect(Array.from(got.data)).toEqual(Array.from(orig.data));
}
});
@@ -1137,6 +1414,56 @@ describe("conversation attachments round-trip", () => {
expect(valid).toBe(true);
});
+ test("an inline html sibling does not steal the PDF's IMAP path", () => {
+ // A mail client that also sends text/html leaves an extra sibling
+ // between BODY[1.1] and the attachment. extractAttachments skips that
+ // inline part; the PDF's path must still be the sibling number
+ // extractPartByPath uses (1.3), not the attachment-array index (1.2).
+ const pdf = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]);
+ const signed = [
+ `Content-Type: multipart/mixed; boundary="inner"`,
+ "",
+ `--inner`,
+ "Content-Type: text/plain; charset=utf-8",
+ "Content-Transfer-Encoding: 7bit",
+ "",
+ "hello",
+ `--inner`,
+ "Content-Type: text/html; charset=utf-8",
+ "Content-Disposition: inline",
+ "",
+ "hello
",
+ `--inner`,
+ "Content-Type: application/pdf",
+ "Content-Transfer-Encoding: base64",
+ `Content-Disposition: attachment; filename="report.pdf"`,
+ "",
+ "JVBERi0=",
+ `--inner--`,
+ "",
+ ].join("\r\n");
+ const msg = assembleMessage(
+ makeHeaders({ interchangeType: "conversation.message" }),
+ enc.encode(signed),
+ enc.encode("FAKE-SIGNATURE"),
+ );
+
+ const htmlPart = parseMimePart(extractPartByPath(msg, "1.2"));
+ expect(
+ defined(htmlPart.contentType.split(";")[0]).trim().toLowerCase(),
+ ).toBe("text/html");
+ const pdfPart = parseMimePart(extractPartByPath(msg, "1.3"));
+ expect(
+ defined(pdfPart.contentType.split(";")[0]).trim().toLowerCase(),
+ ).toBe("application/pdf");
+
+ const extracted = extractAttachments(msg);
+ expect(extracted).toHaveLength(1);
+ expect(defined(extracted[0]).name).toBe("report.pdf");
+ expect(defined(extracted[0]).part).toBe("1.3");
+ expect(Array.from(defined(extracted[0]).data)).toEqual(Array.from(pdf));
+ });
+
test("rejects an attachment name containing CRLF (header injection)", () => {
expect(() =>
assembleSignedContent({
@@ -1152,4 +1479,45 @@ describe("conversation attachments round-trip", () => {
}),
).toThrow();
});
+
+ test("rejects an attachment header that is not 7-bit or over 998 octets", () => {
+ // These headers sit inside the signed part and have no transfer
+ // encoding. An 8-bit name, or a line past 998, is one a relay can rewrite.
+ const blob = new Uint8Array([1]);
+ expect(() =>
+ assembleSignedContent({
+ kind: "conversation",
+ text: "x",
+ attachments: [
+ { name: "résumé.pdf", contentType: "application/pdf", data: blob },
+ ],
+ }),
+ ).toThrow(/US-ASCII/);
+ expect(() =>
+ assembleSignedContent({
+ kind: "conversation",
+ text: "x",
+ attachments: [
+ {
+ name: "a".repeat(1100),
+ contentType: "application/pdf",
+ data: blob,
+ },
+ ],
+ }),
+ ).toThrow(/998/);
+ expect(() =>
+ assembleSignedContent({
+ kind: "conversation",
+ text: "x",
+ attachments: [
+ {
+ name: "notes.txt",
+ contentType: `application/${"é"}`,
+ data: blob,
+ },
+ ],
+ }),
+ ).toThrow(/US-ASCII/);
+ });
});
diff --git a/vendor/intx-mime/src/index.ts b/vendor/intx-mime/src/index.ts
index a2029561d..64540e770 100644
--- a/vendor/intx-mime/src/index.ts
+++ b/vendor/intx-mime/src/index.ts
@@ -8,11 +8,17 @@ export {
parseMimePart,
parseMultipart,
extractBoundary,
+ extractContentTypeMime,
extractPartByPath,
parseMailToEmail,
extractAttachments,
buildMessageHeaders,
+ decodeHeaderText,
decodeMail,
+ decodePartBytes,
+ isRecognizedTransferEncoding,
+ reportedContentType,
+ transferEncodingMechanism,
} from "./mime";
export type {
diff --git a/vendor/intx-mime/src/mail-builder.test.ts b/vendor/intx-mime/src/mail-builder.test.ts
index 95ca4e37d..27383a8c2 100644
--- a/vendor/intx-mime/src/mail-builder.test.ts
+++ b/vendor/intx-mime/src/mail-builder.test.ts
@@ -50,9 +50,13 @@ describe("createInboundMessage", () => {
test("auto-generated date is a parseable ISO string", () => {
const msg = createInboundMessage({ from: FROM, to: TO, content: "hi" });
- const parsed = new Date(msg.headers.date);
+ const { date } = msg.headers;
+ // The builder mints a date for a caller that supplies none, so an absent
+ // one here is the failure this test exists to catch.
+ if (date === undefined) throw new Error("expected a generated Date header");
+ const parsed = new Date(date);
expect(Number.isNaN(parsed.getTime())).toBe(false);
- expect(msg.headers.date).toBe(parsed.toISOString());
+ expect(date).toBe(parsed.toISOString());
});
test("structured payload sets interchangeType header automatically", () => {
@@ -233,10 +237,39 @@ describe("createInboundMessage", () => {
).toThrow(/`from` must be a non-empty string/);
});
- test("throws when to is an empty array", () => {
+ test("builds a message with no From when from is omitted", () => {
+ // Omitting the originator is how a caller says it has none. An empty
+ // string is still rejected above: that is a caller supplying a
+ // placeholder, which is the fabrication this distinction exists to stop.
+ const msg = createInboundMessage({ to: TO, content: "x" });
+ expect(msg.headers.from).toBeUndefined();
+ expect(msg.headers.messageId).toMatch(/^<[^<>\s@]+@local>$/);
+ });
+
+ test("builds a message with no recipient when to is an empty array", () => {
+ // An empty list is how a caller says the message names no recipient it
+ // can vouch for. Inbound mail reaches this builder with an absent or
+ // unreadable `To` routinely -- the hub routes on an out-of-band address,
+ // not on the header -- and the alternative to recording none is
+ // recording one the caller chose, which reads back as the message's own.
+ const msg = createInboundMessage({ from: FROM, to: [], content: "x" });
+ expect(msg.headers.to).toEqual([]);
+ });
+
+ test("throws when to is an empty string", () => {
+ // The empty list above is the caller having no recipient. An empty
+ // string is a caller supplying a placeholder, which stays refused.
expect(() =>
- createInboundMessage({ from: FROM, to: [], content: "x" }),
- ).toThrow(/`to` must contain at least one recipient address/);
+ createInboundMessage({ from: FROM, to: "", content: "x" }),
+ ).toThrow(/`to` must be a non-empty string/);
+ });
+
+ test("throws when cc is an empty array", () => {
+ // Only `to` carries the no-recipient state; the decoder omits an empty
+ // `cc` rather than emptying it, so an empty list here is a caller error.
+ expect(() =>
+ createInboundMessage({ from: FROM, to: TO, cc: [], content: "x" }),
+ ).toThrow(/`cc` must contain at least one recipient address/);
});
test("throws when to contains an empty string", () => {
diff --git a/vendor/intx-mime/src/mail-builder.ts b/vendor/intx-mime/src/mail-builder.ts
index 65c3ff388..63f7402f2 100644
--- a/vendor/intx-mime/src/mail-builder.ts
+++ b/vendor/intx-mime/src/mail-builder.ts
@@ -22,8 +22,12 @@ import type {
MessageHeaders,
MessageRef,
OutboundMessage,
-} from "@intx/types/runtime";
-import { InterchangeType, SignatureStatus } from "@intx/types/runtime";
+} from "@intx/types/runtime-core";
+import {
+ InterchangeType,
+ SignatureStatus,
+ isConversationType,
+} from "@intx/types/runtime-core";
import { generateMessageId } from "./mime";
/**
@@ -37,8 +41,6 @@ const DEFAULT_PAYLOAD_VERSION = "1";
const MESSAGE_ID_RE = /^<[^<>\s@]+@[^<>\s@]+>$/;
const ADDRESS_RE = /^[^@\s]+@[^@\s]+$/;
-const CONVERSATION_TYPE_PREFIX = "conversation.";
-
// ---------------------------------------------------------------------------
// InboundMessage builder
// ---------------------------------------------------------------------------
@@ -54,7 +56,10 @@ export type InboundPayloadInput = {
};
export type CreateInboundMessageOpts = {
- from: string;
+ /** Originator; omit it when the message has none. */
+ from?: string;
+
+ /** Recipients; pass an empty array when the message names none. */
to: string | string[];
/** Plain-text body. Mutually exclusive with `payload`. */
@@ -111,8 +116,8 @@ export function createInboundMessage(
): InboundMessage {
const fn = "createInboundMessage";
- requireAddress(opts.from, "from", fn);
- const to = normalizeAndValidateAddressArray(opts.to, "to", fn);
+ if (opts.from !== undefined) requireAddress(opts.from, "from", fn);
+ const to = normalizeAndValidateAddressArray(opts.to, "to", fn, true);
validateBodyExclusivity(opts.content, opts.payload, fn);
@@ -168,7 +173,7 @@ export function createInboundMessage(
const cc =
opts.cc === undefined
? undefined
- : normalizeAndValidateAddressArray(opts.cc, "cc", fn);
+ : normalizeAndValidateAddressArray(opts.cc, "cc", fn, false);
rejectEmptyStringIfPresent(opts.content, "content", fn);
rejectEmptyStringIfPresent(opts.subject, "subject", fn);
@@ -202,7 +207,8 @@ export function createInboundMessage(
const messageId = opts.messageId ?? generateMessageId(opts.from);
const derivedInterchangeType = opts.interchangeType ?? opts.payload?.type;
- const headers: MessageHeaders = { from: opts.from, to, date, messageId };
+ const headers: MessageHeaders = { to, date, messageId };
+ if (opts.from !== undefined) headers.from = opts.from;
if (cc !== undefined) headers.cc = cc;
if (opts.subject !== undefined) headers.subject = opts.subject;
if (opts.inReplyTo !== undefined) headers.inReplyTo = opts.inReplyTo;
@@ -310,9 +316,9 @@ export function createOutboundMessage(
validateInterchangeType(opts.type, "type", fn);
// Validate addresses without mutating the source shape; the OutboundMessage
// type preserves `string | string[]` and downstream consumers handle both.
- normalizeAndValidateAddressArray(opts.to, "to", fn);
+ normalizeAndValidateAddressArray(opts.to, "to", fn, false);
if (opts.cc !== undefined) {
- normalizeAndValidateAddressArray(opts.cc, "cc", fn);
+ normalizeAndValidateAddressArray(opts.cc, "cc", fn, false);
}
validateBodyExclusivity(opts.content, opts.payload, fn);
@@ -410,16 +416,21 @@ function requireAddress(value: unknown, field: string, fn: string): void {
}
}
+/**
+ * Validate an address list and normalize a bare string into a one-entry array.
+ * `allowNone` permits an empty list, which only an inbound `to` does.
+ */
function normalizeAndValidateAddressArray(
input: string | string[],
field: string,
fn: string,
+ allowNone: boolean,
): string[] {
if (typeof input === "string") {
requireAddress(input, field, fn);
return [input];
}
- if (!Array.isArray(input) || input.length === 0) {
+ if (!Array.isArray(input) || (input.length === 0 && !allowNone)) {
throw new Error(
`${fn}: \`${field}\` must contain at least one recipient address`,
);
@@ -440,10 +451,6 @@ function validatePayloadBody(value: unknown, field: string, fn: string): void {
}
}
-function isConversationType(t: InterchangeType): boolean {
- return t.startsWith(CONVERSATION_TYPE_PREFIX);
-}
-
function validateInterchangeType(
value: unknown,
field: string,
diff --git a/vendor/intx-mime/src/mail-decode.test.ts b/vendor/intx-mime/src/mail-decode.test.ts
index c297c2914..95e7e24e7 100644
--- a/vendor/intx-mime/src/mail-decode.test.ts
+++ b/vendor/intx-mime/src/mail-decode.test.ts
@@ -1,14 +1,150 @@
import { describe, test, expect } from "bun:test";
import type { MailPart, MessageAttachment } from "@intx/types/runtime";
import { isMail } from "@intx/types/runtime";
+import { deriveMessageId, parseMessageIdHeader } from "@intx/types";
-import { assembleSignedContent, assembleMessage, decodeMail } from "./index";
+import {
+ assembleSignedContent,
+ assembleMessage,
+ decodeMail,
+ extractAttachments,
+ parseMailToEmail,
+} from "./index";
import type { MessageHeaders } from "./index";
function rawBytes(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
+/**
+ * Build message bytes whose body is exactly the given byte values, so a test
+ * can carry a body that is not valid UTF-8.
+ */
+function rawWithBody(headerText: string, bodyBytes: number[]): Uint8Array {
+ const head = new TextEncoder().encode(headerText);
+ const out = new Uint8Array(head.length + bodyBytes.length);
+ out.set(head, 0);
+ out.set(Uint8Array.from(bodyBytes), head.length);
+ return out;
+}
+
+function codeUnits(value: string): number[] {
+ const out: number[] = [];
+ for (let i = 0; i < value.length; i++) out.push(value.charCodeAt(i));
+ return out;
+}
+
+/** A multipart/mixed message with one text/plain part under `encoding`. */
+function multipartWithEncoding(encoding: string, body: string): Uint8Array {
+ const boundary = "cte_boundary";
+ return rawBytes(
+ [
+ "From: alice@example.com",
+ "To: bob@example.com",
+ `Content-Type: multipart/mixed; boundary="${boundary}"`,
+ "",
+ `--${boundary}`,
+ "Content-Type: text/plain",
+ `Content-Transfer-Encoding: ${encoding}`,
+ "",
+ body,
+ `--${boundary}--`,
+ "",
+ ].join("\r\n"),
+ );
+}
+
+/**
+ * A single-part text/plain message under `encoding`. The message's own headers
+ * are the part's headers here, so this is the shape that tells whether an
+ * entry point reads the declared mechanism or a reconstruction that dropped
+ * it.
+ */
+function singlePartWithEncoding(encoding: string, body: string): Uint8Array {
+ return rawBytes(
+ [
+ "From: alice@example.com",
+ "To: bob@example.com",
+ "Content-Type: text/plain",
+ `Content-Transfer-Encoding: ${encoding}`,
+ "",
+ body,
+ ].join("\r\n"),
+ );
+}
+
+/**
+ * A multipart/mixed message whose part 1 is the text body and whose part 2 is
+ * an application/pdf attachment under `encoding`.
+ */
+function multipartWithAttachmentEncoding(
+ encoding: string,
+ body: string,
+): Uint8Array {
+ const boundary = "cte_att_boundary";
+ return rawBytes(
+ [
+ "From: alice@example.com",
+ "To: bob@example.com",
+ `Content-Type: multipart/mixed; boundary="${boundary}"`,
+ "",
+ `--${boundary}`,
+ "Content-Type: text/plain",
+ "",
+ "see attached",
+ `--${boundary}`,
+ "Content-Type: application/pdf",
+ `Content-Transfer-Encoding: ${encoding}`,
+ 'Content-Disposition: attachment; filename="report.pdf"',
+ "",
+ body,
+ `--${boundary}--`,
+ "",
+ ].join("\r\n"),
+ );
+}
+
+/**
+ * A multipart/signed message in the shape `extractAttachments` reads: an outer
+ * signed envelope whose content part is a multipart/mixed carrying the text
+ * body and one application/pdf attachment under `encoding`.
+ */
+function signedWithAttachmentEncoding(
+ encoding: string,
+ body: string,
+): Uint8Array {
+ return rawBytes(
+ [
+ "From: alice@example.com",
+ "To: bob@example.com",
+ 'Content-Type: multipart/signed; protocol="application/pgp-signature"; ' +
+ 'micalg="pgp-sha256"; boundary="cte_outer"',
+ "",
+ "--cte_outer",
+ 'Content-Type: multipart/mixed; boundary="cte_inner"',
+ "",
+ "--cte_inner",
+ "Content-Type: text/plain",
+ "",
+ "see attached",
+ "--cte_inner",
+ "Content-Type: application/pdf",
+ `Content-Transfer-Encoding: ${encoding}`,
+ 'Content-Disposition: attachment; filename="report.pdf"',
+ "",
+ body,
+ "--cte_inner--",
+ "",
+ "--cte_outer",
+ "Content-Type: application/pgp-signature",
+ "",
+ "placeholder-signature",
+ "--cte_outer--",
+ "",
+ ].join("\r\n"),
+ );
+}
+
function headers(overrides: Partial = {}): MessageHeaders {
return {
from: '"Alice" ',
@@ -78,6 +214,75 @@ describe("decodeMail", () => {
);
});
+ test("records an absent Date and Message-ID as absent, not as empty", () => {
+ // RFC 5322 defines `Date` as `date-time` and `Message-ID` as `msg-id`.
+ // Neither admits an empty body, so a message carrying neither has to come
+ // back with both fields absent: a defaulted empty string reads as a value
+ // downstream and gets written back out as a malformed header line.
+ const mail = decodeMail(
+ rawBytes(
+ [
+ "From: alice@example.com",
+ "To: bob@example.com",
+ "Subject: neither header",
+ "Content-Type: text/plain",
+ "",
+ "body",
+ ].join("\r\n"),
+ ),
+ );
+
+ expect(mail.headers.date).toBeUndefined();
+ expect(mail.headers.messageId).toBeUndefined();
+ // Absent, not present-and-undefined: the key must not survive into a
+ // serialized envelope as an explicit null-ish value.
+ expect("date" in mail.headers).toBe(false);
+ expect("messageId" in mail.headers).toBe(false);
+ });
+
+ test("treats a blank Date and Message-ID as absent", () => {
+ // A sender that writes the header and leaves it empty has named nothing,
+ // which is the same treatment a blank `From` already gets.
+ const mail = decodeMail(
+ rawBytes(
+ [
+ "From: alice@example.com",
+ "To: bob@example.com",
+ "Date: ",
+ "Message-ID: ",
+ "Content-Type: text/plain",
+ "",
+ "body",
+ ].join("\r\n"),
+ ),
+ );
+
+ expect("date" in mail.headers).toBe(false);
+ expect("messageId" in mail.headers).toBe(false);
+ });
+
+ test("treats a blank In-Reply-To as absent", () => {
+ // RFC 5322 defines `In-Reply-To` as `1*msg-id` under the same clause as
+ // `Message-ID`, so a header left blank names no parent. An empty value is
+ // an id no message can carry, so every message whose header was blank
+ // would otherwise read as a reply to one shared nonexistent parent.
+ const mail = decodeMail(
+ rawBytes(
+ [
+ "From: alice@example.com",
+ "To: bob@example.com",
+ "Message-ID: ",
+ "In-Reply-To: ",
+ "Content-Type: text/plain",
+ "",
+ "body",
+ ].join("\r\n"),
+ ),
+ );
+
+ expect("inReplyTo" in mail.headers).toBe(false);
+ });
+
test("decodes every part of a message with attachments, no data loss", () => {
const mail = decodeMail(
signedConversation("see attached", [
@@ -127,6 +332,62 @@ describe("decodeMail", () => {
expect(mail.rawHeaders["subject"]).toEqual(["folded subject line"]);
});
+ test("keeps a space parked at the end of a folded line", () => {
+ // The leading whitespace of a continuation is the fold. A space before
+ // the next fold is part of the value, so trimming the continuation
+ // would drop it.
+ const mail = decodeMail(
+ rawBytes(
+ "From: a@b\r\n" +
+ "Subject: hello\r\n" +
+ " world \r\n" +
+ " more\r\n" +
+ "Content-Type: text/plain\r\n\r\nbody",
+ ),
+ );
+ expect(mail.rawHeaders["subject"]).toEqual(["hello world more"]);
+ });
+
+ test("keeps a header named after an Object.prototype member", () => {
+ // `__proto__` and `constructor` are well-formed field names (RFC 5322
+ // section 3.6.8), so a peer can send them. Accumulating them into a plain
+ // object reaches inherited members instead of the accumulator's own
+ // properties, which either throws or discards the value.
+ const mail = decodeMail(
+ rawBytes(
+ "From: a@b\r\n" +
+ "__proto__: injected\r\n" +
+ "Constructor: hostile\r\n" +
+ "Content-Type: text/plain\r\n\r\nbody",
+ ),
+ );
+
+ // Lossless: each value is an own data property, so it survives rather
+ // than being swallowed by the inherited `__proto__` setter.
+ expect(Object.hasOwn(mail.rawHeaders, "__proto__")).toBe(true);
+ expect(
+ Object.getOwnPropertyDescriptor(mail.rawHeaders, "__proto__")?.value,
+ ).toEqual(["injected"]);
+ expect(Object.hasOwn(mail.rawHeaders, "constructor")).toBe(true);
+ expect(mail.rawHeaders["constructor"]).toEqual(["hostile"]);
+
+ // Prototype-free: the map carries no inherited member, so a field name the
+ // message does not carry resolves to nothing rather than to whatever
+ // `Object.prototype` holds under that name -- the declared type promises
+ // `string[] | undefined` for every name, and `toString` would otherwise be
+ // a function. The shared accessor itself is untouched.
+ expect(Object.getPrototypeOf(mail.rawHeaders)).toBeNull();
+ expect(mail.rawHeaders["toString"]).toBeUndefined();
+ expect(mail.rawHeaders["hasOwnProperty"]).toBeUndefined();
+ expect(
+ typeof Object.getOwnPropertyDescriptor(Object.prototype, "__proto__")
+ ?.get,
+ ).toBe("function");
+
+ // Ordinary headers in the same message still parse.
+ expect(mail.rawHeaders["from"]).toEqual(["a@b"]);
+ });
+
test("undoes base64 transfer-encoding on a leaf part", () => {
const mail = decodeMail(
rawBytes(
@@ -159,6 +420,182 @@ describe("decodeMail", () => {
expect(mail.headers.from).toBe("alice@example.com");
});
+ test("refuses a bare carriage return or line feed before either reading", () => {
+ // RFC 5322 section 2.3 requires CR and LF to occur only together as CRLF,
+ // and RFC 5321 section 4.1.1.4 refuses the lone-LF line ending by name, so a
+ // bare break terminates no field. `decodeMail` reads one header section
+ // twice -- once into the typed subset, once into the raw map a workflow
+ // walks -- and refusing the section ahead of both is what keeps the two from
+ // disagreeing about which fields the message carries.
+ for (const brk of ["\r", "\n"]) {
+ expect(() =>
+ decodeMail(
+ rawBytes(
+ "From: alice@example.com\r\n" +
+ "To: bob@example.com\r\n" +
+ `Subject: hello${brk}Bcc: attacker@evil.test\r\n` +
+ `Interchange-Correlation-ID: corr-1${brk}Bcc: attacker@evil.test\r\n` +
+ "\r\nbody",
+ ),
+ ),
+ ).toThrow(/must break its lines with CRLF/);
+ }
+ });
+
+ test("records a blank or absent From as no originator, not an empty one", () => {
+ // A defaulted "" made these two states indistinguishable and handed every
+ // consumer an originator the message never carried.
+ expect(
+ decodeMail(rawBytes("To: bob@example.com\r\n\r\nbody")).headers.from,
+ ).toBeUndefined();
+ expect(
+ decodeMail(rawBytes("From: \r\nTo: bob@example.com\r\n\r\nbody"))
+ .headers.from,
+ ).toBeUndefined();
+ });
+
+ test("carries an unparseable From through verbatim", () => {
+ // This projection is lossless. An originator that is present but not a
+ // parseable address is a different judgement from one that was never
+ // there, and the admission gate keys on exactly that difference.
+ expect(
+ decodeMail(rawBytes("From: not an address\r\n\r\nbody")).headers.from,
+ ).toBe("not an address");
+ });
+
+ test("keeps every header when the header section opens with a blank line", () => {
+ // The raw parse used to break on the leading empty line and return no
+ // headers at all, where the typed parse skipped it and kept both fields.
+ const mail = decodeMail(
+ rawBytes(
+ "\r\nFrom: alice@example.com\r\nTo: bob@example.com\r\n\r\nbody",
+ ),
+ );
+ expect(mail.rawHeaders["from"]).toEqual(["alice@example.com"]);
+ expect(mail.rawHeaders["to"]).toEqual(["bob@example.com"]);
+ expect(mail.headers.from).toBe("alice@example.com");
+ expect(mail.headers.to).toEqual(["bob@example.com"]);
+ });
+
+ test("names no id in either parser for a lone-LF message", async () => {
+ // CRLF is the sole line terminator (RFC 5321 section 2.3.8, section
+ // 4.1.1.4). `decodeMail` refuses these bytes, so the claim-check id
+ // derived for them must not be one read out of the text it refused.
+ const raw = rawBytes("Message-ID: \n\nbody");
+ expect(() => decodeMail(raw)).toThrow(/must break its lines with CRLF/);
+ expect(parseMessageIdHeader(raw)).toBeNull();
+ expect(await deriveMessageId(raw)).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ test("names no id in either parser for an LF-only header section", async () => {
+ // A whole-message search for the `CRLF CRLF` separator finds the one in the
+ // body and reads the header section from there: every field after the first
+ // is folded into it, so `Subject` goes missing, the body is truncated to
+ // what followed the separator, and the id absorbs sender-controlled body
+ // bytes. That id is the claim-check dedup key, a stored filename and a
+ // database join value, so nothing from the body may reach it.
+ const bodyTerminated = rawBytes(
+ "Message-ID: \nSubject: Hi\n\nbody\r\n\r\ntail\n",
+ );
+ expect(() => decodeMail(bodyTerminated)).toThrow(
+ /must break its lines with CRLF/,
+ );
+ expect(parseMessageIdHeader(bodyTerminated)).toBeNull();
+ expect(await deriveMessageId(bodyTerminated)).toMatch(/^[0-9a-f]{64}$/);
+
+ // The same LF-only section, ended by a `CRLF CRLF` of its own rather than
+ // by one the sender left in the body.
+ const crlfTerminated = rawBytes(
+ "Message-ID: \nSubject: Hi\r\n\r\nbody",
+ );
+ expect(() => decodeMail(crlfTerminated)).toThrow(
+ /must break its lines with CRLF/,
+ );
+ expect(parseMessageIdHeader(crlfTerminated)).toBeNull();
+ expect(await deriveMessageId(crlfTerminated)).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ test("refuses an LF LF separator in either parser", async () => {
+ // The blank line is a pair of line breaks of its own. Honouring a bare pair
+ // lets a sender who controls one field body end the header section early and
+ // strip the fields after it, `Message-ID` included.
+ const raw = rawBytes(
+ "From: alice@example.com\r\nSubject: Hi\n\n" +
+ "Message-ID: \r\n\r\nbody",
+ );
+ expect(() => decodeMail(raw)).toThrow(/must break its lines with CRLF/);
+ expect(parseMessageIdHeader(raw)).toBeNull();
+ expect(await deriveMessageId(raw)).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ test("takes the first blank line when the body carries another", () => {
+ const raw = rawBytes(
+ "From: alice@example.com\r\nSubject: Hi\r\n\r\nbody\r\n\r\ntail\r\n",
+ );
+ const mail = decodeMail(raw);
+ expect(mail.headers.from).toBe("alice@example.com");
+ expect(mail.headers.subject).toBe("Hi");
+ expect(mail.parts).toHaveLength(1);
+ expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe(
+ "body\r\n\r\ntail\r\n",
+ );
+ });
+
+ test("treats a Date that does not parse the same as an absent one", () => {
+ // A consumer builds a `Date` from this value and compares it to a search
+ // window's bounds. Every comparison against an Invalid Date is false, so a
+ // kept value places the message inside `before: 1990` and `after: 2999` at
+ // once. The absence reported instead falls outside every window, which is
+ // already how an absent header reads.
+ const absent = decodeMail(rawBytes("From: a@example.com\r\n\r\nbody"));
+ const garbage = decodeMail(
+ rawBytes("From: a@example.com\r\nDate: not a date\r\n\r\nbody"),
+ );
+ expect("date" in absent.headers).toBe(false);
+ expect("date" in garbage.headers).toBe(false);
+ });
+
+ test("never reports a Date a consumer cannot parse", () => {
+ for (const value of ["not a date", "Mon, 32 Foo 2026", " ", ""]) {
+ const mail = decodeMail(
+ rawBytes(`From: a@example.com\r\nDate: ${value}\r\n\r\nbody`),
+ );
+ expect(mail.headers.date).toBeUndefined();
+ }
+
+ const good = decodeMail(
+ rawBytes(
+ "From: a@example.com\r\nDate: Mon, 21 Apr 2026 12:00:00 +0000\r\n\r\nbody",
+ ),
+ );
+ const { date } = good.headers;
+ if (date === undefined) throw new Error("expected a Date header");
+ expect(date).toBe("Mon, 21 Apr 2026 12:00:00 +0000");
+ expect(Number.isNaN(new Date(date).getTime())).toBe(false);
+ });
+
+ test("resolves a leading-WSP line to no field in either parser", async () => {
+ // A field begins with a printable name character (RFC 2822 section 2.2),
+ // so the smuggled line continues nothing and names no field. Reading it as
+ // one made the stored envelope id disagree with the dedup key.
+ const raw = rawBytes(
+ " Message-ID: \r\n" +
+ "Message-ID: \r\n" +
+ "\r\nbody",
+ );
+ const mail = decodeMail(raw);
+ expect(mail.headers.messageId).toBe("");
+ expect(mail.rawHeaders["message-id"]).toEqual([""]);
+ expect(await deriveMessageId(raw)).toBe("");
+
+ const solo = rawBytes(" Message-ID: \r\n\r\nbody");
+ const soloMail = decodeMail(solo);
+ expect(soloMail.headers.messageId).toBeUndefined();
+ expect(soloMail.rawHeaders["message-id"]).toBeUndefined();
+ expect(parseMessageIdHeader(solo)).toBeNull();
+ expect(await deriveMessageId(solo)).toMatch(/^[0-9a-f]{64}$/);
+ });
+
test("throws on a multipart part with no boundary rather than dropping it", () => {
expect(() =>
decodeMail(
@@ -168,6 +605,452 @@ describe("decodeMail", () => {
});
});
+describe("Content-Transfer-Encoding normalization", () => {
+ test("accepts a mechanism that carries a trailing RFC 822 comment", () => {
+ // RFC 2045 section 1: comments in a MIME header field have no semantic
+ // content and are ignored during processing, so this names 7bit.
+ const mail = decodeMail(
+ rawBytes(
+ "Content-Type: text/plain\r\n" +
+ "Content-Transfer-Encoding: 7bit (default)\r\n\r\nhello world",
+ ),
+ );
+ expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe(
+ "hello world",
+ );
+ });
+
+ test("accepts a leading comment and a nested comment", () => {
+ const mail = decodeMail(
+ rawBytes(
+ "Content-Type: text/plain\r\n" +
+ "Content-Transfer-Encoding: (per RFC 2045 (section 6.8)) base64" +
+ "\r\n\r\naGVsbG8gd29ybGQ=",
+ ),
+ );
+ expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe(
+ "hello world",
+ );
+ });
+
+ test("does not let a quoted close-paren end a comment early", () => {
+ // RFC 822 section 3.4.5: a backslash quotes the next character, so the
+ // escaped ')' is comment text rather than the comment terminator.
+ const mail = decodeMail(
+ rawBytes(
+ "Content-Type: text/plain\r\n" +
+ "Content-Transfer-Encoding: base64 (a \\) b)\r\n\r\naGk=",
+ ),
+ );
+ expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe("hi");
+ });
+
+ test("treats a field naming no mechanism as the 7bit default", () => {
+ // RFC 2045 section 6.1 defaults an ABSENT field to 7bit. A field that is
+ // present but names nothing -- empty, or nothing but a comment -- has
+ // declared no mechanism either, so it takes the same default. Routing it
+ // to the unrecognised arm instead treats a body the sender never claimed
+ // to have encoded as opaque bytes, which surfaces to a reader as mojibake
+ // rather than the characters that were sent.
+ for (const encoding of ["", " ", "(just a comment)"]) {
+ const email = parseMailToEmail(
+ multipartWithEncoding(encoding, "caf\u00e9"),
+ "sml_cte",
+ );
+ expect(email.bodyValues["1"]?.value).toBe("caf\u00e9");
+ expect(email.bodyValues["1"]?.isEncodingProblem).toBe(false);
+ }
+ });
+
+ test("ignores a parameter tail on the mechanism", () => {
+ const mail = decodeMail(
+ rawBytes(
+ "Content-Type: text/plain\r\n" +
+ "Content-Transfer-Encoding: base64; x-note=stray\r\n\r\naGk=",
+ ),
+ );
+ expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe("hi");
+ });
+
+ test("returns an unrecognised mechanism's body as opaque bytes", () => {
+ // RFC 2045 section 6.4: an entity with an unrecognised mechanism is
+ // treated as application/octet-stream. Discarding it, or running it
+ // through a text decode, both destroy the bytes the treatment preserves.
+ const body = [0xff, 0xfe, 0x41, 0x00, 0x80];
+ const mail = decodeMail(
+ rawWithBody(
+ "Content-Type: text/plain\r\n" +
+ "Content-Transfer-Encoding: x-uuencode\r\n\r\n",
+ body,
+ ),
+ );
+ expect(mail.parts).toHaveLength(1);
+ expect(Array.from(mail.parts[0]?.content ?? new Uint8Array())).toEqual(
+ body,
+ );
+ });
+
+ test("still throws on a malformed body under a recognised mechanism", () => {
+ // A malformed base64 body is a different condition from an unrecognised
+ // mechanism, and attachment integrity depends on it surfacing.
+ expect(() =>
+ decodeMail(
+ rawBytes(
+ "Content-Type: text/plain\r\n" +
+ "Content-Transfer-Encoding: base64\r\n\r\n!!! not base64 !!!",
+ ),
+ ),
+ ).toThrow();
+ });
+
+ test("parseMailToEmail decodes base64 through an RFC 822 comment", () => {
+ const email = parseMailToEmail(
+ multipartWithEncoding("base64 (RFC 2045)", "aGVsbG8="),
+ "sml_cte_comment",
+ );
+ expect(email.bodyValues["1"]?.value).toBe("hello");
+ expect(email.bodyValues["1"]?.isEncodingProblem).toBe(false);
+ });
+
+ test("parseMailToEmail carries an unrecognised mechanism's bytes intact", () => {
+ // The body is valid UTF-8 for a non-ASCII character, so a UTF-8 decode
+ // would fold its two bytes into one code point and lose the octets that
+ // octet-stream treatment exists to preserve.
+ const email = parseMailToEmail(
+ multipartWithEncoding("x-weird", "café"),
+ "sml_cte_unknown",
+ );
+ expect(codeUnits(email.bodyValues["1"]?.value ?? "")).toEqual([
+ 0x63, 0x61, 0x66, 0xc3, 0xa9,
+ ]);
+ expect(email.bodyValues["1"]?.isEncodingProblem).toBe(true);
+ });
+
+ // RFC 2045 section 6.4 is one instruction with two halves: an entity whose
+ // mechanism is unrecognised is not interpreted, AND it is treated as
+ // application/octet-stream. The reported content type is the second half.
+ // Each row pairs a mechanism with the type a part reports under it and the
+ // body that mechanism yields, so a recognised mechanism is the control that
+ // keeps its declared type and is decoded.
+ const reportedTypeCases: {
+ mechanism: string;
+ textPartBody: string;
+ decodedText: string;
+ attachmentBody: string;
+ reportedTextType: string;
+ reportedAttachmentType: string;
+ }[] = [
+ {
+ mechanism: "7bit",
+ textPartBody: "hello",
+ decodedText: "hello",
+ attachmentBody: "pdf-bytes",
+ reportedTextType: "text/plain",
+ reportedAttachmentType: "application/pdf",
+ },
+ {
+ // No comment to replace, so the value reaches the match untouched.
+ mechanism: "base64",
+ textPartBody: "aGVsbG8=",
+ decodedText: "hello",
+ attachmentBody: "cGRmLWJ5dGVz",
+ reportedTextType: "text/plain",
+ reportedAttachmentType: "application/pdf",
+ },
+ {
+ mechanism: "quoted-printable",
+ textPartBody: "hello",
+ decodedText: "hello",
+ attachmentBody: "pdf-bytes",
+ reportedTextType: "text/plain",
+ reportedAttachmentType: "application/pdf",
+ },
+ {
+ mechanism: "x-uuencode",
+ textPartBody: "hello",
+ decodedText: "hello",
+ attachmentBody: "begin 644 report.pdf",
+ reportedTextType: "application/octet-stream",
+ reportedAttachmentType: "application/octet-stream",
+ },
+ {
+ // A trailing comment leaves the token before it alone, so this row
+ // names x-weird and relabels for the same reason the row above does.
+ mechanism: "x-weird (not a mechanism)",
+ textPartBody: "hello",
+ decodedText: "hello",
+ attachmentBody: "weird-bytes",
+ reportedTextType: "application/octet-stream",
+ reportedAttachmentType: "application/octet-stream",
+ },
+ {
+ // A run of CFWS between two lexical tokens is semantically a single
+ // space (RFC 2822 section 3.2.3), so a comment separates the tokens it
+ // sits between rather than joining them. This value is the two
+ // tokens `ba` and `se64`; neither names a mechanism, and a single
+ // mechanism is all RFC 2045 section 6.1 admits. Replacing the comment
+ // with nothing instead reads it as `base64` and hands an attacker a
+ // body a strict peer leaves opaque and we decode.
+ mechanism: "ba(c)se64",
+ textPartBody: "aGVsbG8=",
+ decodedText: "aGVsbG8=",
+ attachmentBody: "cGRmLWJ5dGVz",
+ reportedTextType: "application/octet-stream",
+ reportedAttachmentType: "application/octet-stream",
+ },
+ {
+ // The same smuggle with the comment at a different interior offset.
+ mechanism: "base(x)64",
+ textPartBody: "aGVsbG8=",
+ decodedText: "aGVsbG8=",
+ attachmentBody: "cGRmLWJ5dGVz",
+ reportedTextType: "application/octet-stream",
+ reportedAttachmentType: "application/octet-stream",
+ },
+ {
+ // One comment between every pair of characters, so no two characters
+ // may be joined at all.
+ mechanism: "b(1)a(2)s(3)e(4)6(5)4",
+ textPartBody: "aGVsbG8=",
+ decodedText: "aGVsbG8=",
+ attachmentBody: "cGRmLWJ5dGVz",
+ reportedTextType: "application/octet-stream",
+ reportedAttachmentType: "application/octet-stream",
+ },
+ {
+ // Separating the tokens must not cost a legal value its mechanism:
+ // this one still names 7bit once the trailing comment becomes a space
+ // and the value is trimmed.
+ mechanism: "7bit (default)",
+ textPartBody: "hello",
+ decodedText: "hello",
+ attachmentBody: "pdf-bytes",
+ reportedTextType: "text/plain",
+ reportedAttachmentType: "application/pdf",
+ },
+ {
+ // Nothing but a comment still names no mechanism, which RFC 2045
+ // section 6.1 defaults to 7bit rather than treating as unrecognised.
+ mechanism: "(just a comment)",
+ textPartBody: "hello",
+ decodedText: "hello",
+ attachmentBody: "pdf-bytes",
+ reportedTextType: "text/plain",
+ reportedAttachmentType: "application/pdf",
+ },
+ ];
+
+ test("decodes a body part by the mechanism its value resolves to", () => {
+ // The companion to the relabel below: the type says how the bytes may be
+ // treated, this says which bytes the reader gets. A value that resolves
+ // to no mechanism is not decoded, so its body arrives as it was sent.
+ for (const c of reportedTypeCases) {
+ const mail = decodeMail(
+ multipartWithEncoding(c.mechanism, c.textPartBody),
+ );
+ expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe(
+ c.decodedText,
+ );
+
+ // The JMAP projection is a second decoder over the same field, so it
+ // reads the same value the same way.
+ const email = parseMailToEmail(
+ multipartWithEncoding(c.mechanism, c.textPartBody),
+ "sml_cte_decoded_body",
+ );
+ expect(email.bodyValues["1"]?.value).toBe(c.decodedText);
+ }
+ });
+
+ test("reads a single-part message's own transfer encoding on both paths", () => {
+ // A single-part message carries its transfer encoding in the message
+ // headers, and `parseMailToEmail` reaches the decoder through a part it
+ // reconstructs from them. A reconstruction that carries only the content
+ // type leaves the decoder with the RFC 2045 section 6.1 default, so a
+ // base64 body arrives still encoded and an unrecognised mechanism's
+ // octets are interpreted as text -- and the two entry points disagree on
+ // the same bytes. Each row asserts the pair agrees.
+ for (const c of reportedTypeCases) {
+ const raw = singlePartWithEncoding(c.mechanism, c.textPartBody);
+
+ const mail = decodeMail(raw);
+ expect(mail.parts).toHaveLength(1);
+ expect(mail.parts[0]?.contentType).toBe(c.reportedTextType);
+ expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe(
+ c.decodedText,
+ );
+
+ const email = parseMailToEmail(raw, "sml_cte_single");
+ expect(email.textBody).toEqual([
+ { partId: "1", type: c.reportedTextType },
+ ]);
+ expect(email.bodyValues["1"]?.value).toBe(c.decodedText);
+ expect(email.bodyValues["1"]?.isEncodingProblem).toBe(
+ c.reportedTextType === "application/octet-stream",
+ );
+ }
+ });
+
+ test("keeps a single-part unrecognised mechanism's octets intact", () => {
+ // The octets, not just the mechanism: a UTF-8 decode folds the two bytes
+ // of a non-ASCII character into one code point, which is the loss that
+ // octet-stream treatment exists to prevent. `decodeMail` already returns
+ // the bytes; the JMAP projection must widen the same ones.
+ const raw = singlePartWithEncoding("x-weird", "café");
+ const octets = [0x63, 0x61, 0x66, 0xc3, 0xa9];
+
+ expect(Array.from(decodeMail(raw).parts[0]?.content ?? [])).toEqual(octets);
+
+ const email = parseMailToEmail(raw, "sml_cte_single_octets");
+ expect(codeUnits(email.bodyValues["1"]?.value ?? "")).toEqual(octets);
+ expect(email.bodyValues["1"]?.isEncodingProblem).toBe(true);
+ });
+
+ test("widens every one of the 256 byte values", () => {
+ // RFC 2045 section 6.4 hands a body under an unrecognised transfer
+ // encoding back as octets, not text. The JMAP string value for such a
+ // body must therefore carry one code unit per byte, with every byte
+ // preserved exactly -- the case above covers one character's worth of the
+ // range, this one covers all of it.
+ //
+ // This is a regression guard against the obvious optimisation.
+ // `new TextDecoder("latin1")` looks like a one-pass replacement for the
+ // widening in `bytesToBinaryString`. It is not: the WHATWG Encoding
+ // Standard makes "latin1" and "iso-8859-1" labels for windows-1252, which
+ // maps 27 of the 256 byte values to a different code point. This test
+ // fails if anyone makes that swap.
+ const body: number[] = [];
+ for (let i = 0; i < 256; i++) body.push(i);
+ const raw = rawWithBody(
+ [
+ "From: alice@example.com",
+ "To: bob@example.com",
+ "Content-Type: text/plain",
+ "Content-Transfer-Encoding: x-uuencode",
+ "",
+ "",
+ ].join("\r\n"),
+ body,
+ );
+
+ const email = parseMailToEmail(raw, "sml_cte_all_octets");
+ expect(email.bodyValues["1"]).toBeDefined();
+ expect(email.bodyValues["1"]?.isEncodingProblem).toBe(true);
+ expect(email.bodyValues["1"]?.value.length).toBe(256);
+ expect(codeUnits(email.bodyValues["1"]?.value ?? "")).toEqual(body);
+ });
+
+ test("names the windows-1252 range a latin1 decode would rewrite", () => {
+ // Documents the trap rather than the product: these are the byte values the
+ // swap would rewrite, asserted against windows-1252 because that is the
+ // encoding the "latin1" label resolves to. Kept beside the test above so
+ // the reason that test exists is visible where it is asserted.
+ const all = new Uint8Array(256);
+ for (let i = 0; i < 256; i++) all[i] = i;
+ const decoded = new TextDecoder("windows-1252").decode(all);
+ const diverging = [...all].filter((b) => decoded.charCodeAt(b) !== b);
+ expect(diverging.length).toBe(27);
+ expect(diverging.every((b) => b >= 0x80 && b <= 0x9f)).toBe(true);
+ });
+
+ test("reports a body part under an unrecognised mechanism as octet-stream", () => {
+ // Without the relabel a consumer is told the part holds text in the
+ // charset it declared while it holds bytes that are not that text, and
+ // every consumer that keys on the type acts on that label.
+ for (const c of reportedTypeCases) {
+ const mail = decodeMail(
+ multipartWithEncoding(c.mechanism, c.textPartBody),
+ );
+ expect(mail.parts).toHaveLength(1);
+ expect(mail.parts[0]?.contentType).toBe(c.reportedTextType);
+
+ // The JMAP projection reports the same type. The part stays listed in
+ // textBody under it rather than dropping out of the list, so a consumer
+ // that walks textBody to find the body still learns the part exists.
+ const email = parseMailToEmail(
+ multipartWithEncoding(c.mechanism, c.textPartBody),
+ "sml_cte_reported_body",
+ );
+ expect(email.textBody).toEqual([
+ { partId: "1", type: c.reportedTextType },
+ ]);
+ expect(email.attachments).toEqual([]);
+ expect(email.bodyValues["1"]).toBeDefined();
+ }
+ });
+
+ test("reports an attachment under an unrecognised mechanism as octet-stream", () => {
+ for (const c of reportedTypeCases) {
+ const mail = decodeMail(
+ multipartWithAttachmentEncoding(c.mechanism, c.attachmentBody),
+ );
+ expect(mail.parts).toHaveLength(2);
+ const att = mail.parts[1];
+ expect(att?.contentType).toBe(c.reportedAttachmentType);
+ // Relabelling the type does not cost the reader the part's identity.
+ expect(att?.filename).toBe("report.pdf");
+ expect(att?.disposition).toBe("attachment");
+ // The body part beside it is untouched: the relabel is per part.
+ expect(mail.parts[0]?.contentType).toBe("text/plain");
+
+ const email = parseMailToEmail(
+ multipartWithAttachmentEncoding(c.mechanism, c.attachmentBody),
+ "sml_cte_reported_att",
+ );
+ expect(email.attachments[0]?.type).toBe(c.reportedAttachmentType);
+ expect(email.attachments[0]?.name).toBe("report.pdf");
+
+ // The MessageAttachment projection a conversation turn is built from.
+ const extracted = extractAttachments(
+ signedWithAttachmentEncoding(c.mechanism, c.attachmentBody),
+ );
+ expect(extracted).toHaveLength(1);
+ expect(extracted[0]?.contentType).toBe(c.reportedAttachmentType);
+ expect(extracted[0]?.name).toBe("report.pdf");
+ }
+ });
+
+ test("keeps the encoding-problem flag beside the relabelled type", () => {
+ // The flag and the relabel are complementary, not alternatives: the flag
+ // says the decode did not happen, the type says what the undecoded bytes
+ // may be treated as. Routing on the relabelled type would reclassify this
+ // part as an attachment and the flag would never be written.
+ const email = parseMailToEmail(
+ multipartWithEncoding("x-weird", "café"),
+ "sml_cte_flag_and_type",
+ );
+ expect(email.textBody[0]?.type).toBe("application/octet-stream");
+ expect(email.bodyValues["1"]?.isEncodingProblem).toBe(true);
+ });
+
+ test("still walks into a multipart wrapper declaring an unrecognised mechanism", () => {
+ // A wrapper's children are located from its declared type, so relabelling
+ // the wrapper would make them unreachable and drop their content -- the
+ // opposite of what section 6.4 protects. The relabel is leaf-only.
+ const mail = decodeMail(
+ rawBytes(
+ [
+ "From: alice@example.com",
+ 'Content-Type: multipart/mixed; boundary="wrap"',
+ "Content-Transfer-Encoding: x-weird",
+ "",
+ "--wrap",
+ "Content-Type: text/plain",
+ "",
+ "inner content",
+ "--wrap--",
+ "",
+ ].join("\r\n"),
+ ),
+ );
+ expect(mail.parts).toHaveLength(1);
+ expect(mail.parts[0]?.contentType).toBe("text/plain");
+ expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe(
+ "inner content",
+ );
+ });
+});
+
describe("isMail", () => {
function validMail(): Record {
const parts: MailPart[] = [
@@ -180,6 +1063,20 @@ describe("isMail", () => {
expect(isMail(validMail())).toBe(true);
});
+ test("rejects rawHeaders that is not a record of string arrays", () => {
+ expect(isMail({ ...validMail(), rawHeaders: { subject: ["hi"] } })).toBe(
+ true,
+ );
+ expect(isMail({ ...validMail(), rawHeaders: { subject: "hi" } })).toBe(
+ false,
+ );
+ expect(isMail({ ...validMail(), rawHeaders: { subject: [1] } })).toBe(
+ false,
+ );
+ expect(isMail({ ...validMail(), rawHeaders: "subject" })).toBe(false);
+ expect(isMail({ ...validMail(), rawHeaders: null })).toBe(false);
+ });
+
test("rejects a MessagePart-shaped part (bytes, no ref)", () => {
// decodeMail returns MessagePart[] (content bytes); only the committed
// MailPart[] (ref) is a Mail, so the in-memory decode is NOT a Mail.
@@ -211,8 +1108,94 @@ describe("isMail", () => {
).toBe(false);
});
- test("rejects headers missing the from/to a consumer dereferences", () => {
+ test("rejects headers missing the to a consumer dereferences", () => {
expect(isMail({ ...validMail(), headers: {} })).toBe(false);
expect(isMail({ ...validMail(), headers: { from: "a@b" } })).toBe(false);
});
+
+ test("accepts a Mail carrying no originator", () => {
+ // Mail with no usable From is still mail. Rejecting it here routed such a
+ // message down the arbitrary-step-value path, where it was stringified
+ // into a text turn with no error and no log.
+ expect(isMail({ ...validMail(), headers: { to: ["c@d"] } })).toBe(true);
+ });
+
+ /** Narrow a value reached out of JSON output, without a type assertion. */
+ function asObject(value: unknown): object {
+ if (typeof value !== "object" || value === null) {
+ throw new Error(`expected an object, got ${typeof value}`);
+ }
+ return value;
+ }
+
+ /**
+ * The map the decoder builds for a message carrying `__proto__` and
+ * `Constructor` as field names. Every value is an own enumerable data
+ * property and the map has no prototype, which is the shape the sibling
+ * "keeps a header named after an Object.prototype member" case pins.
+ */
+ function hostileRawHeaders(): Record {
+ return decodeMail(
+ rawBytes(
+ "From: a@b\r\n" +
+ "To: c@d\r\n" +
+ "__proto__: injected\r\n" +
+ "Constructor: hostile\r\n" +
+ "Content-Type: text/plain\r\n\r\nbody",
+ ),
+ ).rawHeaders;
+ }
+
+ test("accepts the rawHeaders map a hostile field name produces", () => {
+ // The `rawHeaders` index signature must still ACCEPT the map the decoder
+ // actually builds. A rejection here is silent: `isMail` returning false
+ // sends the step input down `synthesizeInputContent` instead of the mail
+ // projection, so an inbound mail carrying a `__proto__` header would reach
+ // the agent as stringified text with its parts and threading headers
+ // dropped, and nothing would raise.
+ const rawHeaders = hostileRawHeaders();
+ expect(Object.getPrototypeOf(rawHeaders)).toBeNull();
+ expect(Object.keys(rawHeaders)).toContain("__proto__");
+ expect(Object.keys(rawHeaders)).toContain("constructor");
+
+ expect(isMail({ ...validMail(), rawHeaders })).toBe(true);
+
+ // The acceptance above is the values passing, not the hostile keys going
+ // uninspected: the same map with a bare string under `__proto__` is
+ // refused, so the index signature does reach that key.
+ const tampered = { ...rawHeaders };
+ Object.defineProperty(tampered, "__proto__", {
+ value: "injected",
+ enumerable: true,
+ writable: true,
+ configurable: true,
+ });
+ expect(isMail({ ...validMail(), rawHeaders: tampered })).toBe(false);
+ });
+
+ test("accepts that map after the JSON round trip the control channel imposes", () => {
+ // Decoded mail crosses the workflow host's control channel as JSON, so the
+ // value `isMail` narrows is `JSON.parse` output rather than the decoder's
+ // own object.
+ const transported: unknown = JSON.parse(
+ JSON.stringify({ ...validMail(), rawHeaders: hostileRawHeaders() }),
+ );
+ const map = asObject(
+ Object.getOwnPropertyDescriptor(asObject(transported), "rawHeaders")
+ ?.value,
+ );
+ // `JSON.parse` writes `__proto__` as an own data property rather than
+ // through the inherited setter, so the entry survives the crossing and does
+ // not become the map's prototype. Asserted before the acceptance below,
+ // which a map that LOST the key would satisfy too.
+ expect(Object.getOwnPropertyDescriptor(map, "__proto__")?.value).toEqual([
+ "injected",
+ ]);
+ expect(Object.getOwnPropertyDescriptor(map, "constructor")?.value).toEqual([
+ "hostile",
+ ]);
+ expect(Object.getPrototypeOf(map)).toBe(Object.prototype);
+
+ expect(isMail(transported)).toBe(true);
+ });
});
diff --git a/vendor/intx-mime/src/mime.ts b/vendor/intx-mime/src/mime.ts
index f842b2594..043845a47 100644
--- a/vendor/intx-mime/src/mime.ts
+++ b/vendor/intx-mime/src/mime.ts
@@ -20,13 +20,13 @@
*/
import { type } from "arktype";
-import { base64Decode, base64Encode } from "@intx/types";
+import { base64Decode, base64Encode } from "@intx/types/base64";
import type {
MessageAttachment,
MessageHeaders as ParsedMessageHeaders,
MessagePart,
-} from "@intx/types/runtime";
-import { InterchangeType } from "@intx/types/runtime";
+} from "@intx/types/runtime-core";
+import { InterchangeType } from "@intx/types/runtime-core";
// ---------------------------------------------------------------------------
// Types
@@ -123,8 +123,11 @@ export type JMAPEmail = {
// Message-ID generation
// ---------------------------------------------------------------------------
-export function generateMessageId(address: string): string {
- const domain = address.includes("@") ? address.split("@")[1]! : "local";
+export function generateMessageId(address?: string): string {
+ const domain =
+ address !== undefined && address.includes("@")
+ ? address.split("@")[1]!
+ : "local";
const uuid = crypto.randomUUID();
return `<${uuid}@${domain}>`;
}
@@ -133,13 +136,59 @@ export function generateMessageId(address: string): string {
// Address normalization
// ---------------------------------------------------------------------------
+function addressListError(addressLine: string): Error {
+ return new Error(
+ `extractAddrSpec: address lists are not supported: ${JSON.stringify(addressLine)}`,
+ );
+}
+
+/**
+ * Refuse a value naming more than one address; report where its `<` opens, or
+ * -1 for a bare `addr-spec`. A `quoted-string` carries `@`, `<` and `,` as
+ * text, so the scan exempts what it spans and refuses unpaired quotes.
+ */
+function scanSingleAddress(trimmed: string, addressLine: string): number {
+ let quoted = false;
+ let angleOpen = -1;
+ let sawUnbracketedAt = false;
+ let i = 0;
+ while (i < trimmed.length) {
+ const ch = trimmed[i];
+ if (quoted && ch === "\\") {
+ i += 2;
+ continue;
+ }
+ if (ch === '"') {
+ quoted = !quoted;
+ } else if (!quoted) {
+ if (ch === ",") {
+ throw addressListError(addressLine);
+ } else if (ch === "@" && angleOpen === -1) {
+ sawUnbracketedAt = true;
+ } else if (ch === "<") {
+ if (angleOpen !== -1 || sawUnbracketedAt) {
+ throw addressListError(addressLine);
+ }
+ angleOpen = i;
+ }
+ }
+ i += 1;
+ }
+ if (quoted) {
+ throw new Error(
+ `extractAddrSpec: unterminated quoted string in ${JSON.stringify(addressLine)}`,
+ );
+ }
+ return angleOpen;
+}
+
/**
* Extract the bare addr-spec (local-part@domain) from a single RFC 5322
* address value. Strips any display name and surrounding angle brackets,
* then lowercases the result so case-insensitive comparison falls out
* naturally.
*
- * Accepted inputs (single-address only — do not pass comma-separated lists):
+ * Accepted inputs (exactly one address):
* `"Display Name" ` → `user@host`
* `Display Name ` → `user@host`
* `` → `user@host`
@@ -148,6 +197,8 @@ export function generateMessageId(address: string): string {
*
* Rejected (throws) inputs:
* - empty or whitespace-only
+ * - a value naming more than one address, however the members are separated
+ * - an input whose double quotes do not pair up
* - input with no `@`
* - input that produces an empty local-part or domain
* - quoted local-parts (e.g. `"a@b"@host`) — technically valid per RFC
@@ -170,8 +221,9 @@ export function extractAddrSpec(addressLine: string): string {
throw new Error("extractAddrSpec: address is empty");
}
+ const angleOpen = scanSingleAddress(trimmed, addressLine);
+
let candidate: string;
- const angleOpen = trimmed.lastIndexOf("<");
if (angleOpen !== -1) {
// Angle-bracketed form. Require the `>` to be the trailing
// non-whitespace character so that input like `Name (comment)`
@@ -182,6 +234,11 @@ export function extractAddrSpec(addressLine: string): string {
);
}
candidate = trimmed.slice(angleOpen + 1, -1).trim();
+ if (candidate.includes(">")) {
+ throw new Error(
+ `extractAddrSpec: stray '>' inside angle brackets in ${JSON.stringify(addressLine)}`,
+ );
+ }
} else {
// Bare form. An unquoted addr-spec carries no internal whitespace, so
// treat any as trailing content (e.g. `a@b (comment)`) and refuse rather
@@ -283,8 +340,311 @@ function generateBoundary(): string {
const CRLF = "\r\n";
+/**
+ * RFC 5322 section 2.1.1 recommends no more than 78 characters per line and
+ * REQUIRES no more than 998. Folding targets the recommendation: a generator
+ * that aims at 998 produces lines no human and few mail clients display well,
+ * and leaves nothing in hand for a relay that prepends to a header.
+ */
+const FOLD_TARGET_OCTETS = 78;
+
+/**
+ * Insert folding whitespace so a line stays near `FOLD_TARGET_OCTETS`.
+ *
+ * Folds at a space that is already in the value, which is the only place RFC
+ * 5322 section 2.2.3 permits. The continuation carries exactly one leading
+ * space, and that space is one space from the value. `parseHeaders` replaces
+ * a fold plus every whitespace character after it with a single space, so a
+ * second leading space would not survive.
+ *
+ * Any earlier space in the same run stays on the line being folded, even when
+ * that carries the line past the target. A token with no space in it is
+ * emitted whole. There is no legal fold point inside a `msg-id` or an `atom`,
+ * so breaking one would corrupt it.
+ *
+ * A run that ends in a tab is not a fold point: the continuation can carry
+ * only one leading space, so the tab would be deleted or turned into a
+ * space. An earlier space that ends its own run is still a fold. Skipping
+ * it to keep the tab on this line can push the line past 998.
+ */
+function foldHeaderLine(name: string, value: string): string {
+ if (value.length === 0) return `${name}:${CRLF}`;
+
+ const head = `${name}: `;
+ let out = head;
+ let column = head.length;
+ let index = 0;
+
+ // Last index of the whitespace run that contains `wsIndex`.
+ const runLast = (wsIndex: number): number => {
+ let last = wsIndex;
+ while (
+ last + 1 < value.length &&
+ (value[last + 1] === " " || value[last + 1] === "\t")
+ ) {
+ last += 1;
+ }
+ return last;
+ };
+
+ // `wsIndex` is whitespace in `value`. The continuation can carry only one
+ // leading space, so the fold has to be a space that ends the run. A tab
+ // after that space would be swallowed with the leading whitespace, and a
+ // tab used as the fold itself would come back as a space. When the run
+ // ends in a tab and no earlier fold remains, the whole run stays here.
+ const foldOnRun = (wsIndex: number): void => {
+ const last = runLast(wsIndex);
+ if (value[last] !== " ") {
+ out += value.slice(index, last + 1);
+ column += last + 1 - index;
+ index = last + 1;
+ return;
+ }
+ out += value.slice(index, last);
+ column += last - index;
+ index = last + 1;
+ out += `${CRLF} `;
+ column = 1;
+ };
+
+ while (index < value.length) {
+ const room = FOLD_TARGET_OCTETS - column;
+ if (room > 0 && value.length - index <= room) {
+ out += value.slice(index);
+ break;
+ }
+
+ // The last space in reach whose run ends in a space. A run that ends
+ // in a tab is skipped so an earlier space can still be the fold.
+ let breakAt = -1;
+ const limit = Math.max(0, Math.min(room, value.length - index));
+ for (let offset = 0; offset < limit; offset += 1) {
+ const at = index + offset;
+ if (value[at] === " " && value[runLast(at)] === " ") breakAt = at;
+ }
+
+ if (breakAt !== -1 || value[index] === " ") {
+ foldOnRun(breakAt === -1 ? index : breakAt);
+ continue;
+ }
+
+ // No space between here and the target, and the next character is not a
+ // space. Emit the token whole rather than inventing a fold point.
+ const nextSpace = value.indexOf(" ", index);
+ const end = nextSpace === -1 ? value.length : nextSpace;
+ out += value.slice(index, end);
+ column += end - index;
+ index = end;
+ }
+
+ return out + CRLF;
+}
+
+/**
+ * Serialize a STRUCTURED header: an address list, a message id, a date, a
+ * content type. Folded, never encoded -- RFC 2047 encoded-words are not
+ * permitted in a structured field's tokens, so a non-ASCII address here needs
+ * SMTPUTF8 and IDNA rather than an encoding this function could apply.
+ */
function hdr(name: string, value: string): string {
- return `${name}: ${value}${CRLF}`;
+ assertNoLineBreaks(value, `${name} header`);
+ return foldHeaderLine(name, value);
+}
+
+/** Bytes of UTF-8 per encoded-word.
+ *
+ * RFC 2047 section 2 caps an encoded-word at 75 characters. `=?UTF-8?B?` and
+ * `?=` spend 12 of them, leaving 63 for base64 -- so 45 input bytes, the
+ * largest multiple of 3 that fits, encode to 60 characters and keep every word
+ * inside the cap. A multiple of 3 also means no word carries base64 padding
+ * except the last.
+ */
+const ENCODED_WORD_PAYLOAD_BYTES = 45;
+
+function isAscii(value: string): boolean {
+ // eslint-disable-next-line no-control-regex
+ return !/[^\x00-\x7F]/.test(value);
+}
+
+/**
+ * Encode `value` as RFC 2047 encoded-words when it is not already ASCII.
+ *
+ * Base64 (`B`) rather than quoted-printable (`Q`) for every value that needs
+ * encoding at all. `Q` produces a more readable wire form for a value that is
+ * mostly ASCII, but it needs a per-character escape table whose omissions are
+ * silent, and it offers nothing a receiver can do that `B` cannot. One code
+ * path that is always correct beats two where one is prettier.
+ *
+ * Words are split on a CHARACTER boundary, never inside a multi-byte sequence:
+ * RFC 2047 section 5 requires each encoded-word to decode independently, and a
+ * sequence straddling two of them decodes to a replacement character in both.
+ */
+function encodeHeaderText(value: string): string {
+ if (isAscii(value)) return value;
+
+ const bytes = new TextEncoder().encode(value);
+ const words: string[] = [];
+ let start = 0;
+
+ while (start < bytes.length) {
+ let end = Math.min(start + ENCODED_WORD_PAYLOAD_BYTES, bytes.length);
+ // Walk back off a continuation byte (10xxxxxx) so the cut lands between
+ // characters rather than inside one.
+ while (end > start && end < bytes.length) {
+ const byte = bytes[end];
+ if (byte === undefined || (byte & 0xc0) !== 0x80) break;
+ end -= 1;
+ }
+ const chunk = bytes.subarray(start, end);
+ words.push(`=?UTF-8?B?${Buffer.from(chunk).toString("base64")}?=`);
+ start = end;
+ }
+
+ // Adjacent encoded-words are joined by whitespace, which a receiver deletes
+ // (RFC 2047 section 6.2). `foldHeaderLine` then folds at those same spaces,
+ // which is why each word has to fit a line on its own.
+ return words.join(" ");
+}
+
+/**
+ * Serialize an UNSTRUCTURED header: text a person reads, such as `Subject`.
+ *
+ * Encoded before folding, because encoding is what creates the fold points: a
+ * long non-ASCII subject has no spaces to fold at until it becomes a sequence
+ * of encoded-words.
+ */
+function hdrText(name: string, value: string): string {
+ assertNoLineBreaks(value, `${name} header`);
+ return foldHeaderLine(name, encodeHeaderText(value));
+}
+
+/**
+ * Decode RFC 2047 encoded-words in a header value that was received.
+ *
+ * Needed for interoperability rather than for our own mail: every sender that
+ * writes a non-ASCII subject encodes it, and a receiver that does not decode
+ * shows an agent `=?UTF-8?B?...?=` as the subject it is supposed to read. Both
+ * `B` and `Q` are decoded even though only `B` is produced here, because the
+ * choice belongs to whoever sent the message.
+ *
+ * Whitespace BETWEEN two encoded-words is deleted and whitespace elsewhere is
+ * kept, per RFC 2047 section 6.2. A word whose charset or encoding this cannot
+ * handle is left exactly as it arrived: showing the encoded form is a visible
+ * fault a reader can report, whereas substituting replacement characters looks
+ * like the sender's own text.
+ */
+export function decodeHeaderText(value: string): string {
+ const word = /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g;
+ if (!word.test(value)) return value;
+ word.lastIndex = 0;
+
+ let out = "";
+ let cursor = 0;
+ let previousWasWord = false;
+
+ for (let match = word.exec(value); match !== null; match = word.exec(value)) {
+ const whole = match[0];
+ const charset = match[1];
+ const encoding = match[2];
+ const payload = match[3];
+ if (
+ charset === undefined ||
+ encoding === undefined ||
+ payload === undefined
+ ) {
+ // Unreachable: all three groups are mandatory in the pattern. Narrowed
+ // rather than asserted so a future pattern edit cannot make it a crash.
+ continue;
+ }
+ const gap = value.slice(cursor, match.index);
+
+ // Whitespace separating two encoded-words is a separator, not content.
+ if (!(previousWasWord && gap.trim() === "")) out += gap;
+
+ const decoded = decodeEncodedWord(charset, encoding, payload);
+ out += decoded ?? whole;
+ previousWasWord = decoded !== undefined;
+ cursor = match.index + whole.length;
+ }
+
+ return out + value.slice(cursor);
+}
+
+/**
+ * Decode a base64 payload only when every character is in the alphabet and
+ * the bytes re-encode to the same text. `Buffer` accepts illegal characters
+ * by ignoring them, which would turn a corrupt encoded-word into different
+ * text instead of leaving the word as it arrived.
+ */
+function decodeBase64Strict(payload: string): Uint8Array | undefined {
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return undefined;
+ if (payload.length % 4 === 1) return undefined;
+ const bytes = Buffer.from(payload, "base64");
+ const canonical = Buffer.from(bytes).toString("base64").replace(/=+$/, "");
+ const given = payload.replace(/=+$/, "");
+ if (canonical !== given) return undefined;
+ return new Uint8Array(bytes);
+}
+
+function decodeEncodedWord(
+ charset: string,
+ encoding: string,
+ payload: string,
+): string | undefined {
+ // RFC 2231 permits a language suffix (`utf-8*en`); the charset is the part
+ // before it.
+ const normalized = charset.toLowerCase().split("*")[0] ?? "";
+ // Only the charsets decoded here. An unsupported one returns undefined so the
+ // caller keeps the encoded form rather than guessing at the bytes.
+ const utf8 =
+ normalized === "utf-8" ||
+ normalized === "utf8" ||
+ normalized === "us-ascii";
+ const latin1 = normalized === "iso-8859-1" || normalized === "latin1";
+ if (!utf8 && !latin1) return undefined;
+
+ let bytes: Uint8Array;
+ if (encoding.toLowerCase() === "b") {
+ const decoded = decodeBase64Strict(payload);
+ if (decoded === undefined) return undefined;
+ bytes = decoded;
+ } else {
+ // Q: `_` is a space, `=XX` is a hex octet. Anything else is literal.
+ const octets: number[] = [];
+ for (let i = 0; i < payload.length; i += 1) {
+ const char = payload[i];
+ if (char === undefined) break;
+ if (char === "_") {
+ octets.push(0x20);
+ continue;
+ }
+ if (char === "=" && i + 2 < payload.length) {
+ const hex = payload.slice(i + 1, i + 3);
+ if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
+ octets.push(parseInt(hex, 16));
+ i += 2;
+ continue;
+ }
+ }
+ octets.push(char.charCodeAt(0) & 0xff);
+ }
+ bytes = new Uint8Array(octets);
+ }
+
+ // Latin-1 needs no decoder: its code points ARE its byte values, which is the
+ // one case where that identity holds.
+ if (latin1) {
+ return Array.from(bytes, (byte) => String.fromCharCode(byte)).join("");
+ }
+
+ try {
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ } catch {
+ // Bytes that are not valid UTF-8 despite the charset saying so. Keeping the
+ // encoded form reports the sender's fault; replacement characters would
+ // read as the sender's own text.
+ return undefined;
+ }
}
function serializeMessageHeaders(
@@ -293,14 +653,16 @@ function serializeMessageHeaders(
): string {
let out = "";
out += hdr("From", h.from);
- out += hdr("To", Array.isArray(h.to) ? h.to.join(", ") : (h.to as string));
+ out += hdr("To", h.to.join(", "));
if (h.cc && h.cc.length > 0) {
out += hdr("Cc", h.cc.join(", "));
}
out += hdr("Date", formatRFC2822Date(h.date));
out += hdr("Message-ID", h.messageId);
if (h.subject !== undefined) {
- out += hdr("Subject", h.subject);
+ // The one unstructured header this assembler writes, and so the only one
+ // whose value is text a person chose rather than a token or an address.
+ out += hdrText("Subject", h.subject);
}
if (h.inReplyTo !== undefined) {
out += hdr("In-Reply-To", h.inReplyTo);
@@ -347,24 +709,186 @@ function serializeMessageHeaders(
// MIME part assembly
// ---------------------------------------------------------------------------
-/**
- * Reject values that would break out of a MIME header. CR/LF in a header
- * value is a header-injection vector; a double quote breaks the quoted
- * `filename="..."` / `name="..."` forms the parser relies on. The MIME
- * layer owns header well-formedness, so it fails loudly here rather than
- * emitting a corrupt envelope.
- */
-function assertHeaderSafe(value: string, field: string): void {
+/** Reject a value that would break out of its header: a CR or LF ends the field. */
+function assertNoLineBreaks(value: string, field: string): void {
if (/[\r\n]/.test(value)) {
throw new Error(
`${field} must not contain CR or LF: ${JSON.stringify(value)}`,
);
}
+}
+
+/**
+ * A double quote would close the `filename="..."` form, and this serializer
+ * emits no escape for an inner quote. A non-ASCII octet is the same class of
+ * defect: these part headers sit inside the signed bytes and carry no
+ * transfer encoding, so an 8-bit header is one a relay may rewrite.
+ */
+function assertAttachmentHeaderSafe(value: string, field: string): void {
+ assertNoLineBreaks(value, field);
if (value.includes('"')) {
throw new Error(
`${field} must not contain a double quote: ${JSON.stringify(value)}`,
);
}
+ if (!isAscii(value)) {
+ throw new Error(`${field} must be US-ASCII: ${JSON.stringify(value)}`);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Text part transfer encoding (RFC 2045 §6.7)
+// ---------------------------------------------------------------------------
+//
+// A text part is labelled `7bit` only when it IS 7-bit: every octet US-ASCII,
+// and every line inside the RFC 5322 §2.1.1 limit. Anything else is
+// quoted-printable. The distinction is load-bearing once the bytes reach a real
+// relay: a part labelled `7bit` that carries 8-bit octets is rewritten by any
+// hop that does not advertise 8BITMIME, and that rewrite changes the exact
+// bytes the PGP/MIME signature covers, so the recipient reports `invalid`.
+// Encoding here keeps the part 7-bit clean, so no hop has a reason to touch it.
+
+/** The longest line quoted-printable permits, including the soft-break `=`. */
+const QP_MAX_LINE = 76;
+
+/** RFC 5322 §2.1.1: a line MUST NOT exceed 998 characters, excluding CRLF. */
+const MAX_LINE_OCTETS = 998;
+
+function hexEscape(byte: number): string {
+ return `=${byte.toString(16).toUpperCase().padStart(2, "0")}`;
+}
+
+/**
+ * Octets RFC 2045 §6.7 rule 2 permits literally: 33-60 and 62-126. The gap at
+ * 61 is `=`, which always escapes because it introduces an escape.
+ */
+function isQuotedPrintableLiteral(byte: number): boolean {
+ return (byte >= 33 && byte <= 60) || (byte >= 62 && byte <= 126);
+}
+
+/**
+ * True when the bytes are already a legal `7bit` body: US-ASCII only, no bare
+ * CR or LF, no NUL, and no line over the RFC 5322 limit. The caller has already
+ * canonicalized line endings to CRLF, so a CR or LF outside a CRLF pair is a
+ * defect rather than a line break to measure.
+ */
+function isSevenBitClean(bytes: Uint8Array): boolean {
+ let lineOctets = 0;
+ for (let i = 0; i < bytes.length; i++) {
+ const byte = bytes[i]!;
+ if (byte === 0x0d && bytes[i + 1] === 0x0a) {
+ i += 1;
+ lineOctets = 0;
+ continue;
+ }
+ if (byte === 0x00 || byte === 0x0d || byte === 0x0a || byte > 0x7f) {
+ return false;
+ }
+ lineOctets += 1;
+ if (lineOctets > MAX_LINE_OCTETS) return false;
+ }
+ return true;
+}
+
+/**
+ * Quoted-printable encode one hard line. Space and tab ride literally except
+ * where they would end an encoded line. RFC 2045 §6.7 rule 3 requires an
+ * escape there: a relay that strips trailing whitespace would otherwise change
+ * the signed bytes. That includes the line a soft break closes, not only the
+ * last line of the input.
+ *
+ * Soft breaks land between whole tokens, never inside an `=XX` escape, so the
+ * escape cannot be split across a line. Escaping a run of spaces writes `=20`
+ * onto the line the break opened, which can fill that line; the token that
+ * caused the break is written only after it still fits.
+ */
+function encodeQuotedPrintableLine(bytes: Uint8Array): string {
+ let out = "";
+ let column = 0;
+
+ // Leave a column for the soft-break `=`. A token is one octet or a
+ // three-octet hex escape, so it always fits once `column` is 0.
+ const fits = (length: number): boolean =>
+ column === 0 || column + length <= QP_MAX_LINE - 1;
+
+ const softBreak = (): void => {
+ out += `=${CRLF}`;
+ column = 0;
+ };
+
+ const writeToken = (token: string): void => {
+ if (!fits(token.length)) {
+ const trailing: string[] = [];
+ while (out.endsWith(" ") || out.endsWith("\t")) {
+ const last = out.at(-1);
+ if (last === undefined) break;
+ trailing.push(last);
+ out = out.slice(0, -1);
+ column -= 1;
+ }
+ softBreak();
+ for (const ws of trailing.reverse()) {
+ const escaped = ws === " " ? "=20" : "=09";
+ if (!fits(escaped.length)) softBreak();
+ out += escaped;
+ column += escaped.length;
+ }
+ }
+ // The escaped whitespace may have filled the line it was moved onto.
+ // That line ends in `0` or `9`, so one more break opens a fresh one.
+ if (!fits(token.length)) softBreak();
+ out += token;
+ column += token.length;
+ };
+
+ for (let i = 0; i < bytes.length; i++) {
+ const byte = bytes[i]!;
+ const atEnd = i === bytes.length - 1;
+ const token =
+ byte === 0x20 || byte === 0x09
+ ? atEnd
+ ? hexEscape(byte)
+ : String.fromCharCode(byte)
+ : isQuotedPrintableLiteral(byte)
+ ? String.fromCharCode(byte)
+ : hexEscape(byte);
+ writeToken(token);
+ }
+ return out;
+}
+
+function encodeQuotedPrintable(text: string): string {
+ const encoder = new TextEncoder();
+ return text
+ .split(CRLF)
+ .map((line) => encodeQuotedPrintableLine(encoder.encode(line)))
+ .join(CRLF);
+}
+
+/**
+ * Resolve a canonicalized text body to the transfer encoding that honestly
+ * describes it, and the body in that encoding. Pass `canonical` already
+ * CRLF-normalized with trailing whitespace stripped per line.
+ */
+function encodeTextBody(canonical: string): {
+ encoding: string;
+ body: string;
+} {
+ if (isSevenBitClean(new TextEncoder().encode(canonical))) {
+ return { encoding: "7bit", body: canonical };
+ }
+ return {
+ encoding: "quoted-printable",
+ body: encodeQuotedPrintable(canonical),
+ };
+}
+
+/** CRLF-normalize and strip trailing whitespace per line (MIME canonical form). */
+function canonicalizeText(text: string): string {
+ return text
+ .split(/\r\n|\r|\n/)
+ .map((line) => line.replace(/[ \t]+$/, ""))
+ .join(CRLF);
}
/**
@@ -396,29 +920,39 @@ function assembleConversationSignedPart(
): Uint8Array {
const boundary = generateBoundary();
- // Canonicalize the text part: CRLF line endings, strip trailing
- // whitespace per line.
- const lines = text.split(/\r\n|\r|\n/);
- const canonLines = lines.map((l) => l.replace(/[ \t]+$/, ""));
- const canonical = canonLines.join(CRLF);
+ const text7bit = encodeTextBody(canonicalizeText(text));
let body = `Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}`;
// Text part (BODY[1.1])
body += `--${boundary}${CRLF}`;
body += `Content-Type: text/plain; charset=utf-8${CRLF}`;
- body += `Content-Transfer-Encoding: 7bit${CRLF}`;
+ body += `Content-Transfer-Encoding: ${text7bit.encoding}${CRLF}`;
body += `${CRLF}`;
- body += `${canonical}${CRLF}`;
+ body += `${text7bit.body}${CRLF}`;
// Attachment parts (BODY[1.2..N])
for (const att of attachments) {
- assertHeaderSafe(att.contentType, "attachment contentType");
- assertHeaderSafe(att.name, "attachment name");
+ assertAttachmentHeaderSafe(att.contentType, "attachment contentType");
+ assertAttachmentHeaderSafe(att.name, "attachment name");
+ // A filename is one token, so folding cannot bring it back under the
+ // limit. Refuse it here rather than emit a line a relay must break.
+ const typeLine = `Content-Type: ${att.contentType}`;
+ const dispositionLine = `Content-Disposition: attachment; filename="${att.name}"`;
+ if (typeLine.length > MAX_LINE_OCTETS) {
+ throw new Error(
+ `attachment contentType header exceeds ${MAX_LINE_OCTETS} octets`,
+ );
+ }
+ if (dispositionLine.length > MAX_LINE_OCTETS) {
+ throw new Error(
+ `attachment name header exceeds ${MAX_LINE_OCTETS} octets`,
+ );
+ }
body += `--${boundary}${CRLF}`;
- body += `Content-Type: ${att.contentType}${CRLF}`;
+ body += `${typeLine}${CRLF}`;
body += `Content-Transfer-Encoding: base64${CRLF}`;
- body += `Content-Disposition: attachment; filename="${att.name}"${CRLF}`;
+ body += `${dispositionLine}${CRLF}`;
body += `${CRLF}`;
body += `${base64Lines(att.data)}${CRLF}`;
}
@@ -437,26 +971,28 @@ function assembleStructuredSignedPart(
summary?: string,
): Uint8Array {
const boundary = generateBoundary();
- const jsonStr = JSON.stringify(json);
+ // The payload is machine-read, so it is encoded rather than canonicalized:
+ // stripping trailing whitespace inside a JSON string literal would change the
+ // value, and JSON.stringify emits no bare CR or LF to normalize.
+ const jsonPart = encodeTextBody(JSON.stringify(json));
let body = `Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}`;
// JSON payload part
body += `--${boundary}${CRLF}`;
body += `Content-Type: application/vnd.interchange+json; charset=utf-8${CRLF}`;
- body += `Content-Transfer-Encoding: 7bit${CRLF}`;
+ body += `Content-Transfer-Encoding: ${jsonPart.encoding}${CRLF}`;
body += `${CRLF}`;
- body += `${jsonStr}${CRLF}`;
+ body += `${jsonPart.body}${CRLF}`;
// Optional human-readable summary
if (summary !== undefined) {
+ const summaryPart = encodeTextBody(canonicalizeText(summary));
body += `--${boundary}${CRLF}`;
body += `Content-Type: text/plain; charset=utf-8${CRLF}`;
- body += `Content-Transfer-Encoding: 7bit${CRLF}`;
+ body += `Content-Transfer-Encoding: ${summaryPart.encoding}${CRLF}`;
body += `${CRLF}`;
- const lines = summary.split(/\r\n|\r|\n/);
- const canonLines = lines.map((l) => l.replace(/[ \t]+$/, ""));
- body += `${canonLines.join(CRLF)}${CRLF}`;
+ body += `${summaryPart.body}${CRLF}`;
}
body += `--${boundary}--${CRLF}`;
@@ -554,25 +1090,90 @@ export function assembleSignedContent(
// MIME parsing (for fetchHeaders, fetchStructure, fetchPart, fetchFull)
// ---------------------------------------------------------------------------
-const CRLF_CRLF = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]);
-const LF_LF = new Uint8Array([0x0a, 0x0a]);
+const CR = 0x0d;
+const LF = 0x0a;
+
+/**
+ * Refuse a header section that breaks a line with anything but CRLF (RFC 5321
+ * §2.3.8, §4.1.1.4). The caller passes the section together with the blank line
+ * that ends it, so neither an LF-only section nor an `LF LF` separator survives.
+ *
+ * Neither reading of a bare break is safe. Splitting on it resolves a field
+ * from a value the sender smuggled inside one it controls; folding it collapses
+ * every field after it into the value of the field that carries it, suppressing
+ * each one. A non-conforming peer is owed an error rather than either parse.
+ */
+function assertNoBareLineBreaks(section: Uint8Array): void {
+ for (let i = 0; i < section.length; i++) {
+ const byte = section[i]!;
+ if (byte === CR && section[i + 1] === LF) {
+ i += 1;
+ continue;
+ }
+ if (byte === CR || byte === LF) {
+ throw new Error(
+ "parseHeaderSection: a header section must break its lines with CRLF",
+ );
+ }
+ }
+}
+
+/** The length of the line break starting at `at`, or 0 when none starts there. */
+function lineBreakLength(raw: Uint8Array, at: number): number {
+ const byte = raw[at];
+ if (byte === CR) return raw[at + 1] === LF ? 2 : 1;
+ if (byte === LF) return 1;
+ return 0;
+}
-function findByteSequence(haystack: Uint8Array, needle: Uint8Array): number {
- if (needle.length === 0) return 0;
- const limit = haystack.length - needle.length;
- outer: for (let i = 0; i <= limit; i++) {
- for (let j = 0; j < needle.length; j++) {
- if (haystack[i + j] !== needle[j]) continue outer;
+/**
+ * Locate the blank line that ends the header section: the first line break
+ * immediately followed by another, whichever flavour either uses. Returns null
+ * for a message carrying no blank line.
+ *
+ * Searching for `CRLF CRLF` alone walks past a blank line written with bare
+ * breaks and takes the section from a later offset, so every field before that
+ * offset reads as one field body. Admitting the bare blank line as a separator
+ * is the mirror of that: a sender who controls one field body ends the section
+ * early and strips the fields after it. This search locates the boundary and
+ * `assertNoBareLineBreaks` then refuses the section unless CRLF wrote it,
+ * including the blank line itself.
+ *
+ * The pair is the whole test, so a body part that carries no header fields --
+ * legal under RFC 2046 §5.1.1, which makes every field of a part optional --
+ * offers one leading break, and its content reads as the header section. A
+ * leading break is not admitted as a separator here because the message path
+ * relies on this scan to step over a spurious blank line and keep the fields
+ * behind it; a strict reading of a leading break would take those fields as
+ * body and erase the message's originator. The two paths want opposite
+ * readings of the same leading break, and this scan serves the message.
+ */
+function findHeaderBoundary(
+ raw: Uint8Array,
+): { headerEnd: number; bodyOffset: number } | null {
+ let i = 0;
+ while (i < raw.length) {
+ const first = lineBreakLength(raw, i);
+ if (first === 0) {
+ i += 1;
+ continue;
+ }
+ const second = lineBreakLength(raw, i + first);
+ if (second !== 0) {
+ return { headerEnd: i, bodyOffset: i + first + second };
}
- return i;
+ i += first;
}
- return -1;
+ return null;
}
/**
* Parse the header section of a raw RFC 2822 message.
* Returns a map of lowercase header names to their values, and the
* byte offset where the body starts.
+ *
+ * Throws for a header section that CRLF did not write, the blank line ending it
+ * included. A message with no blank line at all is all header section.
*/
export function parseHeaderSection(raw: Uint8Array): {
headers: Map;
@@ -581,22 +1182,13 @@ export function parseHeaderSection(raw: Uint8Array): {
} {
const headers = new Map();
- // Search for the blank line separator in byte space so the returned
- // offset is valid for Uint8Array.slice() even when headers contain
- // multi-byte UTF-8 characters.
- const crlfIdx = findByteSequence(raw, CRLF_CRLF);
- const lfIdx = findByteSequence(raw, LF_LF);
+ // The boundary is located in byte space so the returned offset is valid for
+ // Uint8Array.slice() even when headers contain multi-byte UTF-8 characters.
+ const boundary = findHeaderBoundary(raw);
+ const headerEnd = boundary === null ? raw.length : boundary.headerEnd;
+ const bodyOffset = boundary === null ? raw.length : boundary.bodyOffset;
- let bodyOffset = raw.length;
- let headerEnd = raw.length;
-
- if (crlfIdx !== -1 && (lfIdx === -1 || crlfIdx <= lfIdx)) {
- headerEnd = crlfIdx;
- bodyOffset = crlfIdx + 4;
- } else if (lfIdx !== -1) {
- headerEnd = lfIdx;
- bodyOffset = lfIdx + 2;
- }
+ assertNoBareLineBreaks(raw.subarray(0, bodyOffset));
const headerText = new TextDecoder("utf-8", { fatal: false }).decode(
raw.subarray(0, headerEnd),
@@ -606,14 +1198,30 @@ export function parseHeaderSection(raw: Uint8Array): {
return { headers, bodyOffset, headerEnd };
}
+/**
+ * Fold a bare CR or LF -- one not part of a CRLF -- to a space. Splitting on it
+ * would let a sender who controls a field body append a field of its own.
+ *
+ * Both callers read a region `parseHeaderSection` has already refused a bare
+ * break in, so nothing reaches this today. It stays because folding is the safe
+ * outcome for a region that refusal ever stops covering, and splitting is not.
+ */
+function foldBareLineBreaks(text: string): string {
+ return text.replace(/\r(?!\n)|(?): void {
// Unfold continuation lines (lines starting with whitespace per RFC 2822).
- const unfolded = headerSection
- .replace(/\r\n[ \t]+/g, " ")
- .replace(/\n[ \t]+/g, " ");
- const lines = unfolded.split(/\r\n|\n/);
+ const unfolded = foldBareLineBreaks(headerSection).replace(
+ /\r\n[ \t]+/g,
+ " ",
+ );
+ const lines = unfolded.split(CRLF);
for (const line of lines) {
if (line.trim() === "") continue;
+ // A field begins with a printable name character (RFC 2822 2.2), so a
+ // leading-WSP line continues nothing and names no field.
+ if (line.startsWith(" ") || line.startsWith("\t")) continue;
const colon = line.indexOf(":");
if (colon === -1) continue;
const name = line.slice(0, colon).trim().toLowerCase();
@@ -859,6 +1467,112 @@ function parseDateHeader(value: string | undefined): string | null {
return date.toISOString();
}
+/**
+ * Replace every RFC 822 comment in a header value with a single space -- not
+ * nothing, or `ba(c)se64` composes a token the sender never wrote. Comments
+ * nest (RFC 822 §3.4.3), and a backslash quotes the next character (§3.4.5).
+ */
+function replaceCommentsWithSpace(value: string): string {
+ const kept: string[] = [];
+ let depth = 0;
+ for (let i = 0; i < value.length; i++) {
+ const ch = value.charAt(i);
+ if (depth > 0 && ch === "\\") {
+ i++;
+ continue;
+ }
+ if (ch === "(") {
+ if (depth === 0) kept.push(" ");
+ depth++;
+ continue;
+ }
+ if (ch === ")" && depth > 0) {
+ depth--;
+ continue;
+ }
+ if (depth === 0) kept.push(ch);
+ }
+ return kept.join("");
+}
+
+/**
+ * Resolve the Content-Transfer-Encoding mechanism a part declares. RFC 2045
+ * §6.1 makes it a single case-insensitive token admitting no parameters.
+ */
+export function transferEncodingMechanism(
+ headers: Map,
+): string {
+ const withoutComments = replaceCommentsWithSpace(
+ headers.get("content-transfer-encoding") ?? "",
+ );
+ const semicolon = withoutComments.indexOf(";");
+ const mechanism =
+ semicolon === -1 ? withoutComments : withoutComments.slice(0, semicolon);
+ const named = mechanism.trim().toLowerCase();
+ // A field naming no mechanism takes the same default as an absent one.
+ return named === "" ? "7bit" : named;
+}
+
+const RECOGNIZED_TRANSFER_ENCODINGS = new Set([
+ "base64",
+ "quoted-printable",
+ "7bit",
+ "8bit",
+ "binary",
+]);
+
+export function isRecognizedTransferEncoding(mechanism: string): boolean {
+ return RECOGNIZED_TRANSFER_ENCODINGS.has(mechanism);
+}
+
+/**
+ * The content type a leaf part reports, after the RFC 2045 §6.4 rule that an
+ * entity under an unrecognized transfer encoding is treated as
+ * application/octet-stream. A `multipart/*` wrapper is exempt: its children
+ * are located from its declared type. The relabel governs what a part
+ * reports, not how it is routed.
+ */
+export function reportedContentType(
+ declaredContentType: string,
+ headers: Map,
+): string {
+ if (extractContentTypeMime(declaredContentType).startsWith("multipart/")) {
+ return declaredContentType;
+ }
+ const mechanism = transferEncodingMechanism(headers);
+ if (isRecognizedTransferEncoding(mechanism)) return declaredContentType;
+ return "application/octet-stream";
+}
+
+/**
+ * The number of bytes widened per `String.fromCharCode` call. Large enough
+ * that the per-call cost is amortised over the buffer, small enough to stay
+ * well clear of the engine's limit on argument count.
+ */
+const BINARY_WIDEN_CHUNK_SIZE = 8192;
+
+/**
+ * Widen bytes into a string of one code unit per byte. A UTF-8 decode would
+ * fold multi-byte sequences and replace any byte that is not valid UTF-8, and
+ * no `TextDecoder` label widens bytes either: the WHATWG Encoding Standard
+ * makes "latin1" and "iso-8859-1" labels for windows-1252, which maps 27 of
+ * the byte values in 0x80-0x9f to other code points.
+ *
+ * Widening runs a chunk at a time. One `String.fromCharCode` per byte
+ * allocates a single-character string per byte plus an array to hold them,
+ * which on a body of tens of megabytes costs several times the body's own
+ * size in resident memory, on a caller that runs synchronously.
+ */
+function bytesToBinaryString(bytes: Uint8Array): string {
+ let out = "";
+ for (let i = 0; i < bytes.length; i += BINARY_WIDEN_CHUNK_SIZE) {
+ out += String.fromCharCode(
+ ...bytes.subarray(i, i + BINARY_WIDEN_CHUNK_SIZE),
+ );
+ }
+ return out;
+}
+
/**
* Decode a MIME body part, handling Content-Transfer-Encoding.
*/
@@ -866,9 +1580,7 @@ function decodeBodyBytes(
body: Uint8Array,
headers: Map,
): { value: string; isEncodingProblem: boolean } {
- const cte = (headers.get("content-transfer-encoding") ?? "7bit")
- .trim()
- .toLowerCase();
+ const cte = transferEncodingMechanism(headers);
if (cte === "base64") {
try {
@@ -886,16 +1598,34 @@ function decodeBodyBytes(
if (cte === "quoted-printable") {
const raw = new TextDecoder("utf-8", { fatal: false }).decode(body);
- return { value: decodeQuotedPrintable(raw), isEncodingProblem: false };
+ // Decode to octets first, then read those octets as UTF-8. A
+ // quoted-printable escape names one OCTET, so a multi-byte character
+ // arrives as several escapes; reading each escape as a character instead
+ // yields one code point per octet, which renders as mojibake.
+ return {
+ value: new TextDecoder("utf-8", { fatal: false }).decode(
+ decodeQuotedPrintableBytes(raw),
+ ),
+ isEncodingProblem: false,
+ };
}
- // 7bit, 8bit, binary — decode as UTF-8
- return {
- value: new TextDecoder("utf-8", { fatal: false }).decode(body),
- isEncodingProblem: false,
- };
+ if (cte === "7bit" || cte === "8bit" || cte === "binary") {
+ return {
+ value: new TextDecoder("utf-8", { fatal: false }).decode(body),
+ isEncodingProblem: false,
+ };
+ }
+
+ // RFC 2045 §6.4: an unrecognized mechanism is a byte stream, not text.
+ return { value: bytesToBinaryString(body), isEncodingProblem: true };
}
+/**
+ * Undo quoted-printable into a string of one code unit per decoded OCTET. The
+ * result is an octet sequence widened into a string, not text: a caller that
+ * wants text decodes those octets under the part's charset.
+ */
function decodeQuotedPrintable(text: string): string {
return text
.replace(/=\r\n/g, "")
@@ -905,6 +1635,16 @@ function decodeQuotedPrintable(text: string): string {
);
}
+/** The octets a quoted-printable body carries, narrowed from the widened form. */
+function decodeQuotedPrintableBytes(text: string): Uint8Array {
+ const widened = decodeQuotedPrintable(text);
+ const out = new Uint8Array(widened.length);
+ for (let i = 0; i < widened.length; i++) {
+ out[i] = widened.charCodeAt(i);
+ }
+ return out;
+}
+
/**
* Determine whether a MIME part is an attachment based on Content-Disposition
* and content type.
@@ -925,7 +1665,7 @@ function isAttachmentPart(
return true;
}
-function extractContentTypeMime(contentType: string): string {
+export function extractContentTypeMime(contentType: string): string {
return contentType.split(";")[0]!.trim().toLowerCase();
}
@@ -963,9 +1703,9 @@ function walkMimePart(
ctx: WalkContext,
): void {
const part = parseMimePart(partBytes);
- const mime = extractContentTypeMime(part.contentType);
+ const declaredMime = extractContentTypeMime(part.contentType);
- if (mime.startsWith("multipart/")) {
+ if (declaredMime.startsWith("multipart/")) {
const boundary = extractBoundary(part.contentType);
if (boundary === undefined) return;
const subParts = parseMultipart(part.body, boundary);
@@ -975,12 +1715,14 @@ function walkMimePart(
return;
}
+ const reportedMime = reportedContentType(declaredMime, part.headers);
+
if (isAttachmentPart(part.contentType, part.headers)) {
const blobId = `blob_${ctx.mailId}_${partPath}`;
ctx.attachments.push({
blobId,
name: extractFilename(part.headers),
- type: mime,
+ type: reportedMime,
size: part.body.length,
});
return;
@@ -989,10 +1731,12 @@ function walkMimePart(
const decoded = decodeBodyBytes(part.body, part.headers);
ctx.bodyValues[partPath] = decoded;
- if (mime === "text/plain") {
- ctx.textBody.push({ partId: partPath, type: mime });
- } else if (mime === "text/html") {
- ctx.htmlBody.push({ partId: partPath, type: mime });
+ // Listed on the declared type but reported under the relabeled one: a
+ // consumer walking `textBody` would otherwise never learn the part exists.
+ if (declaredMime === "text/plain") {
+ ctx.textBody.push({ partId: partPath, type: reportedMime });
+ } else if (declaredMime === "text/html") {
+ ctx.htmlBody.push({ partId: partPath, type: reportedMime });
}
}
@@ -1042,13 +1786,18 @@ export function parseMailToEmail(raw: Uint8Array, mailId: string): JMAPEmail {
});
}
} else {
- // Single-part message (e.g. text/plain).
- // Reconstruct minimal part bytes with content-type header so parseMimePart works.
+ // Single-part message. Reconstruct minimal part bytes so parseMimePart
+ // works; the message's own Content-Transfer-Encoding travels with the type.
const enc = new TextEncoder();
- const ctHeader = `Content-Type: ${contentType}\r\n\r\n`;
- const partBytes = new Uint8Array(enc.encode(ctHeader).length + body.length);
- partBytes.set(enc.encode(ctHeader), 0);
- partBytes.set(body, enc.encode(ctHeader).length);
+ const cte = msgHeaders.get("content-transfer-encoding");
+ const partHeaderText =
+ `Content-Type: ${contentType}\r\n` +
+ (cte === undefined ? "" : `Content-Transfer-Encoding: ${cte}\r\n`) +
+ "\r\n";
+ const partHeaderBytes = enc.encode(partHeaderText);
+ const partBytes = new Uint8Array(partHeaderBytes.length + body.length);
+ partBytes.set(partHeaderBytes, 0);
+ partBytes.set(body, partHeaderBytes.length);
walkMimePart(partBytes, "1", ctx);
}
@@ -1060,10 +1809,13 @@ export function parseMailToEmail(raw: Uint8Array, mailId: string): JMAPEmail {
}
}
+ // The agent reads this field as text. `decodeMail` keeps the wire form.
+ const rawSubject = msgHeaders.get("subject");
+
return {
from: parseAddressList(msgHeaders.get("from") ?? ""),
to: parseAddressList(msgHeaders.get("to") ?? ""),
- subject: msgHeaders.get("subject") ?? null,
+ subject: rawSubject === undefined ? null : decodeHeaderText(rawSubject),
sentAt: parseDateHeader(msgHeaders.get("date")),
bodyValues: ctx.bodyValues,
textBody: ctx.textBody,
@@ -1077,17 +1829,20 @@ export function parseMailToEmail(raw: Uint8Array, mailId: string): JMAPEmail {
* Decode a MIME part body into raw bytes, honoring Content-Transfer-Encoding.
*
* Unlike `decodeBodyBytes` (which produces a JMAP string value), this returns
- * the actual bytes for reconstructing a `MessageAttachment`. A malformed
- * base64 body surfaces as a thrown error rather than a silent best-effort
- * decode — attachment integrity is load-bearing.
+ * the actual bytes. A malformed base64 body throws; an unrecognized mechanism
+ * does not — RFC 2045 §6.4 hands back the bytes as they came.
+ *
+ * Limitation: the quoted-printable branch reads the wire text as UTF-8
+ * before undoing the escapes, so a raw 8-bit byte in a quoted-printable
+ * body (which RFC 2045 forbids) comes out as a replacement character
+ * truncated to one byte rather than the original octet. Escaped bytes
+ * (`=E9`, `=C3=A9`) round-trip exactly.
*/
-function decodeAttachmentBytes(
+export function decodePartBytes(
body: Uint8Array,
headers: Map,
): Uint8Array {
- const cte = (headers.get("content-transfer-encoding") ?? "7bit")
- .trim()
- .toLowerCase();
+ const cte = transferEncodingMechanism(headers);
if (cte === "base64") {
const raw = new TextDecoder("utf-8", { fatal: false }).decode(body);
@@ -1096,26 +1851,17 @@ function decodeAttachmentBytes(
if (cte === "quoted-printable") {
const raw = new TextDecoder("utf-8", { fatal: false }).decode(body);
- const decoded = decodeQuotedPrintable(raw);
- const out = new Uint8Array(decoded.length);
- for (let i = 0; i < decoded.length; i++) {
- out[i] = decoded.charCodeAt(i);
- }
- return out;
+ return decodeQuotedPrintableBytes(raw);
}
- if (cte === "7bit" || cte === "8bit" || cte === "binary") {
- return body;
- }
-
- throw new Error(
- `decodeAttachmentBytes: unsupported content-transfer-encoding "${cte}"`,
- );
+ // Identity encoding, including anything unrecognized (RFC 2045 §6.4).
+ return body;
}
/**
* Extract conversation attachments from raw message bytes as
- * `MessageAttachment[]` with decoded payloads.
+ * `MessageAttachment[]` with decoded payloads and the IMAP part path
+ * of each sibling (`part`), matching `extractPartByPath` numbering.
*
* The conversation signed content is a multipart/mixed whose first part is
* the text body and whose remaining attachment parts (Content-Disposition:
@@ -1147,13 +1893,20 @@ export function extractAttachments(raw: Uint8Array): MessageAttachment[] {
if (innerBoundary === undefined) return [];
const attachments: MessageAttachment[] = [];
- for (const subPartBytes of parseMultipart(signed.body, innerBoundary)) {
+ for (const [index, subPartBytes] of parseMultipart(
+ signed.body,
+ innerBoundary,
+ ).entries()) {
const subPart = parseMimePart(subPartBytes);
if (!isAttachmentPart(subPart.contentType, subPart.headers)) continue;
attachments.push({
name: extractFilename(subPart.headers) ?? "attachment",
- contentType: extractContentTypeMime(subPart.contentType),
- data: decodeAttachmentBytes(subPart.body, subPart.headers),
+ contentType: reportedContentType(
+ extractContentTypeMime(subPart.contentType),
+ subPart.headers,
+ ),
+ data: decodePartBytes(subPart.body, subPart.headers),
+ part: `1.${String(index + 1)}`,
});
}
return attachments;
@@ -1175,7 +1928,6 @@ function isInterchangeType(s: string): s is InterchangeType {
export function buildMessageHeaders(
headers: Map,
): ParsedMessageHeaders {
- const from = headers.get("from") ?? "";
const toRaw = headers.get("to") ?? "";
const to = toRaw
? toRaw
@@ -1184,10 +1936,30 @@ export function buildMessageHeaders(
.filter(Boolean)
: [];
- const date = headers.get("date") ?? "";
- const messageId = headers.get("message-id") ?? "";
+ const result: ParsedMessageHeaders = { to };
+
+ // A blank header below is recorded as an absence, not an empty value: RFC
+ // 5322 admits no empty `Date`, `Message-ID`, `In-Reply-To` or `From` body.
+ //
+ // A `Date` that does not parse is an absence too, which the parse check below
+ // covers along with the blank one. A consumer builds a `Date` from this string
+ // and compares it to a window's bounds; every comparison against an Invalid
+ // Date is false, so a value kept here places the message inside every date
+ // window, including two that exclude each other. An absence falls outside all
+ // of them.
+ const date = headers.get("date");
+ if (date !== undefined && !Number.isNaN(new Date(date).getTime())) {
+ result.date = date;
+ }
+
+ const messageId = headers.get("message-id");
+ if (messageId !== undefined && messageId.trim().length > 0) {
+ result.messageId = messageId;
+ }
- const result: ParsedMessageHeaders = { from, to, date, messageId };
+ // A non-blank value rides through verbatim even when it is not parseable.
+ const from = headers.get("from");
+ if (from !== undefined && from.trim().length > 0) result.from = from;
const ccRaw = headers.get("cc");
if (ccRaw !== undefined) {
@@ -1205,10 +1977,15 @@ export function buildMessageHeaders(
}
const inReplyTo = headers.get("in-reply-to");
- if (inReplyTo !== undefined) result.inReplyTo = inReplyTo;
+ if (inReplyTo !== undefined && inReplyTo.trim().length > 0) {
+ result.inReplyTo = inReplyTo;
+ }
+ // Decoded, because a subject is text to read: a sender that wrote a
+ // non-ASCII one encoded it, and the agent reading this field wants the
+ // characters rather than their RFC 2047 transport form.
const subject = headers.get("subject");
- if (subject !== undefined) result.subject = subject;
+ if (subject !== undefined) result.subject = decodeHeaderText(subject);
const listId = headers.get("list-id");
if (listId !== undefined) result.listId = listId;
@@ -1257,21 +2034,36 @@ function parseRawHeaders(
raw: Uint8Array,
headerEnd: number,
): Record {
- const text = new TextDecoder("utf-8", { fatal: false }).decode(
- raw.subarray(0, headerEnd),
+ const text = foldBareLineBreaks(
+ new TextDecoder("utf-8", { fatal: false }).decode(
+ raw.subarray(0, headerEnd),
+ ),
);
- const out: Record = {};
+ // `__proto__` and `constructor` are legal RFC 5322 field names that collide
+ // with Object.prototype members, so the accumulator is a Map.
+ const out = new Map();
let current: { name: string; value: string } | null = null;
const flush = (): void => {
if (current === null) return;
const key = current.name.trim().toLowerCase();
- (out[key] ??= []).push(current.value.trim());
+ const values = out.get(key) ?? [];
+ values.push(current.value.trim());
+ out.set(key, values);
current = null;
};
- for (const line of text.split(/\r\n|\n/)) {
- if (line === "") break;
- if ((line.startsWith(" ") || line.startsWith("\t")) && current !== null) {
- current.value += ` ${line.trim()}`;
+ for (const line of text.split(CRLF)) {
+ // `headerEnd` bounds the section already, so a blank line here is a
+ // leading one, not the terminator; breaking would drop every field.
+ if (line === "") continue;
+ // Nothing to continue means no field (RFC 2822 2.2), not a field of its own.
+ if (line.startsWith(" ") || line.startsWith("\t")) {
+ if (current !== null) {
+ // The leading whitespace is the fold, and unfolding replaces that
+ // run with one space. Trailing whitespace is content: trimming it
+ // would drop a space the fold left at the end of the line.
+ const body = line.replace(/^[ \t]+/, "");
+ current.value += ` ${body}`;
+ }
continue;
}
const idx = line.indexOf(":");
@@ -1280,7 +2072,10 @@ function parseRawHeaders(
current = { name: line.slice(0, idx), value: line.slice(idx + 1) };
}
flush();
- return out;
+ // Null prototype for the same reason: an absent name must resolve to nothing.
+ const result: Record = Object.create(null);
+ for (const [name, values] of out) result[name] = values;
+ return result;
}
function parseDisposition(
@@ -1316,8 +2111,8 @@ function collectLeafParts(partBytes: Uint8Array): MessagePart[] {
return parseMultipart(part.body, boundary).flatMap(collectLeafParts);
}
const result: MessagePart = {
- contentType: mime,
- content: decodeAttachmentBytes(part.body, part.headers),
+ contentType: reportedContentType(mime, part.headers),
+ content: decodePartBytes(part.body, part.headers),
};
const filename = extractFilename(part.headers);
if (filename !== null) result.filename = filename;
diff --git a/vendor/intx-storage-isogit/PATCHES.md b/vendor/intx-storage-isogit/PATCHES.md
index a81a0bf89..9125a584e 100644
--- a/vendor/intx-storage-isogit/PATCHES.md
+++ b/vendor/intx-storage-isogit/PATCHES.md
@@ -19,6 +19,20 @@ Re-carried as-is: upstream did not touch `src/store.ts` or
`src/store.test.ts` in this range (`loadErrors` still absent upstream),
so both files and the entry are byte-identical to the prior pin.
+### 2026-10-14 re-sync to `4cc6972` (CL-10656, HEAD drift 0.4.0 → base)
+
+Re-carried: upstream touched `store.ts` in this range for the first time
+since vendoring (nine commits in `packages/storage-isogit`, net +15/−16);
+the `store-ts-load-errors` entry three-way merged cleanly (`git
+merge-file`) with zero adaptation, and `loadErrors` is still absent
+upstream at `4cc6972`. Upstream `e75eee2` ("Remove sync.request and the
+undeploy's agent-state push") dropped `createStatePack`'s only remaining
+hub-side consumer and the two comment blocks in `pack-send.ts` /
+`repo-lock.ts` that described the state-pack flow (`createDeployPack`
+itself keeps its export); the vendored-side safety of that removal is
+recorded in docs/VENDORING.md. The tree now types off the same
+`4cc6972` pin as the coupled `@intx/types`/`@intx/inference` trio.
+
## store-ts-load-errors
`store.ts` — Implements `AuditStore.loadErrors` by reading
diff --git a/vendor/intx-storage-isogit/src/gc.ts b/vendor/intx-storage-isogit/src/gc.ts
index b8ce7f67e..353c59281 100644
--- a/vendor/intx-storage-isogit/src/gc.ts
+++ b/vendor/intx-storage-isogit/src/gc.ts
@@ -1,6 +1,6 @@
import git from "isomorphic-git";
import { getLogger } from "@intx/log";
-import { hasCode } from "@intx/types";
+import { hasCode } from "@intx/types/has-code";
import { collectReachableObjects } from "./object-walk";
import { publishPackAtomically } from "./pack-receive";
import {
diff --git a/vendor/intx-storage-isogit/src/mail-store.ts b/vendor/intx-storage-isogit/src/mail-store.ts
index 168d1e08d..ea5b71592 100644
--- a/vendor/intx-storage-isogit/src/mail-store.ts
+++ b/vendor/intx-storage-isogit/src/mail-store.ts
@@ -1,6 +1,7 @@
import git from "isomorphic-git";
import { parseHeaderSection } from "@intx/mime";
-import { hasCode, hexEncode } from "@intx/types";
+import { hasCode } from "@intx/types/has-code";
+import { hexEncode } from "@intx/types/hex";
import { AUTHOR } from "./init";
import type { CommitSigner } from "./signer";
import {
diff --git a/vendor/intx-storage-isogit/src/node-metrics.ts b/vendor/intx-storage-isogit/src/node-metrics.ts
index 189b5b75d..9281c13a4 100644
--- a/vendor/intx-storage-isogit/src/node-metrics.ts
+++ b/vendor/intx-storage-isogit/src/node-metrics.ts
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
-import { hasCode } from "@intx/types";
+import { hasCode } from "@intx/types/has-code";
import type { RepoDiskUsage, RepoObjectCounts } from "./repo-disk";
// Synchronous Node counterparts to the runtime-backed helpers in repo-disk.ts.
diff --git a/vendor/intx-storage-isogit/src/pack-receive.ts b/vendor/intx-storage-isogit/src/pack-receive.ts
index dadd7ccdf..0abdcdb69 100644
--- a/vendor/intx-storage-isogit/src/pack-receive.ts
+++ b/vendor/intx-storage-isogit/src/pack-receive.ts
@@ -2,7 +2,7 @@ import git from "isomorphic-git";
import { readRawObject } from "./isogit-helpers";
import { withRepoDirLock } from "./repo-lock";
import { decodeUTF8, flushRuntime, type StorageRuntime } from "./runtime";
-import { hasCode } from "@intx/types";
+import { hasCode } from "@intx/types/has-code";
/**
* Verifies the signature embedded in a git commit object.
diff --git a/vendor/intx-storage-isogit/src/pack-send.ts b/vendor/intx-storage-isogit/src/pack-send.ts
index 20a75e3b3..f18456d82 100644
--- a/vendor/intx-storage-isogit/src/pack-send.ts
+++ b/vendor/intx-storage-isogit/src/pack-send.ts
@@ -6,9 +6,8 @@ import type { StorageRuntime } from "./runtime";
/**
* Create a git packfile containing all objects reachable from a ref.
*
- * Used by the hub to produce deploy packs for transfer to sidecars, and by
- * the sidecar to produce state packs. The caller sends the resulting bytes
- * as chunked repo.pack.push frames.
+ * Used by the hub to produce deploy packs for transfer to sidecars. The
+ * caller sends the resulting bytes as chunked repo.pack.push frames.
*/
export async function createDeployPack(
runtime: StorageRuntime,
diff --git a/vendor/intx-storage-isogit/src/repo-disk.ts b/vendor/intx-storage-isogit/src/repo-disk.ts
index 61f5d7b4d..73a5fc55a 100644
--- a/vendor/intx-storage-isogit/src/repo-disk.ts
+++ b/vendor/intx-storage-isogit/src/repo-disk.ts
@@ -1,5 +1,5 @@
import git from "isomorphic-git";
-import { hasCode } from "@intx/types";
+import { hasCode } from "@intx/types/has-code";
import type { StorageRuntime } from "./runtime";
/**
diff --git a/vendor/intx-storage-isogit/src/repo-lock.ts b/vendor/intx-storage-isogit/src/repo-lock.ts
index ae0e71b4b..916f59281 100644
--- a/vendor/intx-storage-isogit/src/repo-lock.ts
+++ b/vendor/intx-storage-isogit/src/repo-lock.ts
@@ -7,8 +7,8 @@ import type { StorageRuntime } from "./runtime";
*
* On the sidecar a single agent repo is written by several independent
* drivers — the reactor's context commits, the mail-audit commits, deploy
- * pack applies — and read by the state-pack producer, none of which share a
- * higher-level lock. Garbage collection prunes loose objects and packs, so
+ * pack applies — none of which share a higher-level lock. Garbage
+ * collection prunes loose objects and packs, so
* it cannot run concurrently with any of them without risking the deletion
* of an object a writer just produced. This lock makes the storage layer
* the single owner of that constraint: every mutator and the collector
diff --git a/vendor/intx-storage-isogit/src/runtime.ts b/vendor/intx-storage-isogit/src/runtime.ts
index 814194670..8668fb89d 100644
--- a/vendor/intx-storage-isogit/src/runtime.ts
+++ b/vendor/intx-storage-isogit/src/runtime.ts
@@ -1,5 +1,5 @@
import type { FsClient } from "isomorphic-git";
-import { hasCode } from "@intx/types";
+import { hasCode } from "@intx/types/has-code";
const textDecoder = new TextDecoder();
diff --git a/vendor/intx-storage-isogit/src/store.ts b/vendor/intx-storage-isogit/src/store.ts
index 968edf019..639a2f9fd 100644
--- a/vendor/intx-storage-isogit/src/store.ts
+++ b/vendor/intx-storage-isogit/src/store.ts
@@ -13,7 +13,7 @@ import {
type ConversationTurn,
type ConnectorThreadState,
type PendingOperation,
-} from "@intx/types/runtime";
+} from "@intx/types/runtime-core";
import { type } from "arktype";
import {
AuditRecord,
@@ -31,7 +31,7 @@ import {
import { withRepoDirLock } from "./repo-lock";
import { maybeGCUnderLock, type GCPolicy } from "./gc";
import { decodeUTF8, flushRuntime, type StorageRuntime } from "./runtime";
-import { hasCode } from "@intx/types";
+import { hasCode } from "@intx/types/has-code";
const TURNS_FILE = "turns.jsonl";
const PROMPT_FILE = "prompt.jsonl";
@@ -52,8 +52,8 @@ function blobExtensionFor(contentType: string | undefined): string {
}
const ConnectorThreadStateSchema = type({
- threadRoot: "string",
- lastMessageId: "string",
+ "threadRoot?": "string",
+ "lastMessageId?": "string",
replyTo: "string",
cc: "string[]",
"subject?": "string",
@@ -69,7 +69,6 @@ const ConversationTurnSchema = type({
const PendingOperationSchema = type({
correlationId: "string",
kind: "'approval'",
- "expectedFrom?": "string",
registeredAt: "number",
gateId: "string",
"timeoutAt?": "number",
diff --git a/vendor/intx-tools-posix/PATCHES.md b/vendor/intx-tools-posix/PATCHES.md
index 1414a3399..fd6b0aa97 100644
--- a/vendor/intx-tools-posix/PATCHES.md
+++ b/vendor/intx-tools-posix/PATCHES.md
@@ -26,7 +26,9 @@ describe. Companion to `size-cap-ts-paged-read-file` in
`tools-posix.test.ts` — carries a small registry-contract test asserting
the description names `Use offset=` and paging, so the wording cannot
-drift from the behavior it documents.
+drift from the behavior it documents. Upstream did not touch this package
+in the `5453d0b0` → `4cc6972` range (CL-10656 re-sync verified: the diff
+is empty), so the entry carried byte-identically.
**Disposition:** Companion to `size-cap-ts-paged-read-file` (vendor/
intx-inference). **Removal path:** the same upstream PR that adds the
diff --git a/vendor/intx-types/PATCHES.md b/vendor/intx-types/PATCHES.md
index 16bb1a18b..014601483 100644
--- a/vendor/intx-types/PATCHES.md
+++ b/vendor/intx-types/PATCHES.md
@@ -29,6 +29,65 @@ byte-identically with zero adaptation (`stopReason` chain verified at
`src/testing/` subpackage (`waitUntil`), which the vendored
`package.json` now exposes via the upstream `./testing` subpath export.
+### 2026-10-14 re-sync to `4cc6972` (CL-10656)
+
+All three entries re-carried; none dropped as upstream-absorbed (grep of the
+pristine `4cc6972` `packages/types` tree: `loadErrors`, `stopReason`,
+`normalizeError`, `credentialFailureOrdinal` all absent upstream). Upstream
+split `runtime.ts` into `runtime-core.ts` (runtime contracts) + the new
+`inference-events.ts` (event union). `runtime.ts` is those split
+re-exports plus the compat re-export of `formatSafetyRatingText`
+(about lines 1–11; see the entry below). The patch sites follow the split:
+
+- `inference.usage` stopReason → both declarations in
+ `inference-events.ts` (arktype mapping and the TS union) carry the
+ `types-ts-usage-stop-reason` marker.
+- `AuditStore.loadErrors` → `runtime-core.ts`, marker intact.
+- Retry-context (`RetryDecision.error?`, `RetrySituation.source` /
+ `credentialFailureOrdinal` / `credentialFailureHistory`,
+ `RetryPolicy.normalizeError`) → `runtime-core.ts`; the sites moved
+ unchanged from the old `runtime.ts`, and each now carries the
+ `runtime-ts-auth-recovery-context` marker (previously only the
+ `mediated-credential.ts` site carried one).
+- `mediated-credential.ts` was untouched upstream; its entry carried
+ byte-identically.
+
+Upstream also added `adapter-manifest.ts` (adapter manifest schema),
+`attachment-validation.ts`, `inbound-mail-policy.ts`,
+`credential-delivery.ts`, `format-safety-rating.ts`, `repo.ts`,
+`workflow-definition.ts`, `workflow-lifecycle.ts` — all carried verbatim,
+with the subpath exports the adopted vendored consumers import
+(`runtime-core`, `attachments`, `credential-delivery`,
+`format-safety-rating`, `inference-events`) wired into the vendored
+`package.json`. Resume audit (same day): the first pass wired only the
+five consumer-used new subpaths and dropped fifteen upstream exports
+(`base64`, `signals`, `hex`, `has-code`, `agent-address`,
+`workflow-run-id`, `message-id`, `concat`, `grants`, `grant-snapshot`,
+`credential-cipher`, `repo`, `inbound-mail-policy`,
+`workflow-definition`, `adapter-manifest`) that upstream's export map
+carries; since the vendored tree is wholesale, the vendored export map
+now mirrors upstream's 33 entries verbatim (src paths instead of dist).
+The vendored `@intx/inference` `package.json` likewise gained the
+`./manifest` subpath it was missing.
+The `sync.request` frame and the `agent.undeploy.ack`
+`statePushed` field left `sidecar.ts` with upstream `e75eee2`; first-party
+`src/` has zero references to either (grep evidence recorded in
+docs/VENDORING.md).
+
+## vendor-types-ts-runtime-format-safety-rating-compat
+
+`runtime.ts` — after the upstream moved `formatSafetyRatingText` to
+`format-safety-rating.ts`, the barrel keeps one compat re-export because
+the published `@corbits/openai-responses@0.2.1` dist imports it from the
+`./runtime` subpath (`dist/protocol/body.js`), and npm has no newer
+release that compiles against the new subpath. Without the re-export,
+`bun run build` fails to bundle the installed dist.
+
+**Disposition:** Compat shim, not a divergence of behavior. **Removal
+path:** the `@corbits/openai-responses` release whose dist imports
+`@intx/types/format-safety-rating`; drop this entry and the re-export
+lines in the same change.
+
## runtime-ts-auth-recovery-context
`mediated-credential.ts` and `runtime.ts` — credential material can atomically
diff --git a/vendor/intx-types/README.md b/vendor/intx-types/README.md
index 1ffe2e90c..4acdee19e 100644
--- a/vendor/intx-types/README.md
+++ b/vendor/intx-types/README.md
@@ -6,9 +6,9 @@ wire frames. Nearly every other package imports from here, which
makes this the canonical home for any shape that crosses a package
boundary.
-Each entry point pairs an ArkType validator with its inferred
-TypeScript type so consumers can validate at the boundary and
-trust the resulting value internally.
+Runtime validators expose their inferred TypeScript types so consumers can
+validate at the boundary and trust the resulting value internally. Dedicated
+helper entry points avoid loading unrelated schema collections.
## Surface
@@ -23,6 +23,15 @@ pull in the shapes they need:
discovery view), observability, sidecar status enums (distinct from
the wire frames under `@intx/types/sidecar` below), run addresses,
hex and base64 helpers, and the `hasCode` error guard.
+- `@intx/types/hex` — hexadecimal byte encoding and decoding.
+- `@intx/types/message-id` — canonical Message-ID parsing and derivation.
+- `@intx/types/credential-cipher` — credential cipher contracts and the
+ authenticated-data helpers for sealed credentials and signing keys.
+- `@intx/types/grants` — grant creation, update, and evaluation schemas,
+ grant requirements, approval items, and effect/origin enums.
+- `@intx/types/adapter-manifest` — the operator-declared custom inference
+ adapter manifest schema and `parseAdapterManifestEnv`, the reader for
+ `SIDECAR_ADAPTER_MANIFEST`.
- `@intx/types/authz` — grant rules, condition contexts, and
authorization result shapes shared between `@intx/authz` and the
hub.
@@ -30,14 +39,33 @@ pull in the shapes they need:
`ErrorRecord` shapes for tool-authorization and error records.
- `@intx/types/content-type` — `detectResponseKind`, which classifies a
response's `Headers` as an SSE stream or a JSON body.
-- `@intx/types/runtime` — inference and harness contracts:
+- `@intx/types/runtime` — aggregate entry for runtime contracts and
+ inference-event wire schemas.
+- `@intx/types/runtime-core` — inference and harness contracts:
`ContextStore`, `ToolRunner`, `ToolDefinition`, `AuditStore`,
`InferenceSource` (the resolved provider/model/credential a call
- executes against), retry policy, director and reactor types.
+ executes against), retry policy, director and reactor types. Use this
+ lighter entry in child processes that need runtime contracts without
+ constructing the inference-event union.
+- `@intx/types/inference-events` — the `InferenceEvent` wire validator,
+ its discriminated TypeScript type, and `parseInferenceEvent`.
+- `@intx/types/inbound-mail-policy` — `InboundMailOutcome`,
+ `AuthorControllableOutcome`, and the authored `InboundMailPolicy` schema.
+- `@intx/types/format-safety-rating` — `formatSafetyRatingText`, the plain-text
+ rendering helper, without runtime schema initialization.
- `@intx/types/runtime-capabilities` — the capability-registry
contract harness extensions resolve against (e.g. mail transport,
blob reader).
- `@intx/types/sidecar` — hub-sidecar WebSocket wire frames.
+- `@intx/types/credential-delivery` — credential material and binding
+ descriptors delivered to running workflow children.
+- `@intx/types/repo` — `RepoKind`, `RepoAction`, and `RepoId` without the
+ sidecar frame schemas.
+- `@intx/types/workflow-definition` — `workflowDefinitionEnvelopeSchema`,
+ the structural workflow envelope validator used by definition loaders
+ and the repository substrate.
+- `@intx/types/grant-snapshot` — `GrantWalkSnapshot`, the per-step grant
+ declarations and workflow grant requirements serialized for deployment.
- `@intx/types/grant-wire` — grant-update wire frames pushed from
the hub to the sidecar.
- `@intx/types/tool-packages` — schemas for the tool-package
@@ -59,4 +87,16 @@ pull in the shapes they need:
- `@intx/types/workflow-sources` — schemas for where a code-sourced
workflow definition's bytes come from: `WorkflowDefinitionRegistrySource`,
`WorkflowDefinitionAssetSource` (with its `tarball` and `source`
- `package` arms), unioned as `WorkflowDefinitionSource`.
+ `package` arms), unioned as `WorkflowDefinitionSource`, and the shared
+ `workflowSourceAssetMountPath` layout helper.
+- `@intx/types/attachments` — MIME classification, attachment categories,
+ and attachment size/count limits.
+- `@intx/types/signals` — signal kinds, approval decisions, and correlation
+ signal-name helpers.
+- `@intx/types/agent-address` — formatting and parsing run mail addresses.
+- `@intx/types/workflow-run-id` — `deriveWorkflowRunId`, which extracts a
+ deployment's top-level run ID from its mail address and throws on an
+ invalid address.
+- `@intx/types/base64` — byte encoding and decoding.
+- `@intx/types/concat` — `concatBytes` for combining byte arrays.
+- `@intx/types/has-code` — the `hasCode` error guard.
diff --git a/vendor/intx-types/package.json b/vendor/intx-types/package.json
index bdfa481af..5b41811fb 100644
--- a/vendor/intx-types/package.json
+++ b/vendor/intx-types/package.json
@@ -24,6 +24,10 @@
"types": "./src/runtime.ts",
"default": "./src/runtime.ts"
},
+ "./runtime-core": {
+ "types": "./src/runtime-core.ts",
+ "default": "./src/runtime-core.ts"
+ },
"./runtime-capabilities": {
"types": "./src/runtime-capabilities.ts",
"default": "./src/runtime-capabilities.ts"
@@ -55,6 +59,82 @@
"./testing": {
"types": "./src/testing/index.ts",
"default": "./src/testing/index.ts"
+ },
+ "./attachments": {
+ "types": "./src/attachments.ts",
+ "default": "./src/attachments.ts"
+ },
+ "./credential-delivery": {
+ "types": "./src/credential-delivery.ts",
+ "default": "./src/credential-delivery.ts"
+ },
+ "./format-safety-rating": {
+ "types": "./src/format-safety-rating.ts",
+ "default": "./src/format-safety-rating.ts"
+ },
+ "./inference-events": {
+ "types": "./src/inference-events.ts",
+ "default": "./src/inference-events.ts"
+ },
+ "./inbound-mail-policy": {
+ "types": "./src/inbound-mail-policy.ts",
+ "default": "./src/inbound-mail-policy.ts"
+ },
+ "./workflow-definition": {
+ "types": "./src/workflow-definition.ts",
+ "default": "./src/workflow-definition.ts"
+ },
+ "./hex": {
+ "types": "./src/hex.ts",
+ "default": "./src/hex.ts"
+ },
+ "./grant-snapshot": {
+ "types": "./src/grant-snapshot.ts",
+ "default": "./src/grant-snapshot.ts"
+ },
+ "./repo": {
+ "types": "./src/repo.ts",
+ "default": "./src/repo.ts"
+ },
+ "./base64": {
+ "types": "./src/base64.ts",
+ "default": "./src/base64.ts"
+ },
+ "./signals": {
+ "types": "./src/signals.ts",
+ "default": "./src/signals.ts"
+ },
+ "./agent-address": {
+ "types": "./src/agent-address.ts",
+ "default": "./src/agent-address.ts"
+ },
+ "./has-code": {
+ "types": "./src/has-code.ts",
+ "default": "./src/has-code.ts"
+ },
+ "./concat": {
+ "types": "./src/concat.ts",
+ "default": "./src/concat.ts"
+ },
+ "./workflow-run-id": {
+ "types": "./src/workflow-run-id.ts",
+ "default": "./src/workflow-run-id.ts"
+ },
+ "./message-id": {
+ "types": "./src/message-id.ts",
+ "default": "./src/message-id.ts"
+ },
+ "./credential-cipher": {
+ "types": "./src/credential-cipher.ts",
+ "default": "./src/credential-cipher.ts"
+ },
+ "./grants": {
+ "types": "./src/grants.ts",
+ "default": "./src/grants.ts"
+ },
+ "./adapter-manifest": {
+ "types": "./src/adapter-manifest.ts",
+ "default": "./src/adapter-manifest.ts"
}
},
"dependencies": {
diff --git a/vendor/intx-types/src/adapter-manifest.test.ts b/vendor/intx-types/src/adapter-manifest.test.ts
new file mode 100644
index 000000000..70b1af87d
--- /dev/null
+++ b/vendor/intx-types/src/adapter-manifest.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from "bun:test";
+
+import { parseAdapterManifestEnv } from "./adapter-manifest";
+
+describe("parseAdapterManifestEnv", () => {
+ test("treats an absent or blank value as no declaration", () => {
+ expect(parseAdapterManifestEnv(undefined)).toBeUndefined();
+ expect(parseAdapterManifestEnv("")).toBeUndefined();
+ expect(parseAdapterManifestEnv(" ")).toBeUndefined();
+ });
+
+ test("returns an empty manifest for an empty array", () => {
+ expect(parseAdapterManifestEnv("[]")).toEqual([]);
+ });
+
+ test("returns the entries of a valid manifest", () => {
+ const entries = [
+ { provider: "openai-responses", specifier: "./adapter", export: "make" },
+ ];
+ expect(parseAdapterManifestEnv(JSON.stringify(entries))).toEqual(entries);
+ });
+
+ test("rejects malformed JSON", () => {
+ expect(() => parseAdapterManifestEnv("{not json")).toThrow(
+ /SIDECAR_ADAPTER_MANIFEST is not valid JSON/,
+ );
+ });
+
+ test("rejects a value of the wrong shape", () => {
+ for (const raw of [
+ '{"provider":"x"}',
+ '[{"provider":1}]',
+ '[{"provider":"a:b","specifier":"x","export":"y"}]',
+ ]) {
+ expect(() => parseAdapterManifestEnv(raw)).toThrow(
+ /SIDECAR_ADAPTER_MANIFEST failed validation/,
+ );
+ }
+ });
+});
diff --git a/vendor/intx-types/src/adapter-manifest.ts b/vendor/intx-types/src/adapter-manifest.ts
new file mode 100644
index 000000000..cd2ff1229
--- /dev/null
+++ b/vendor/intx-types/src/adapter-manifest.ts
@@ -0,0 +1,43 @@
+import { type } from "arktype";
+
+import { ModelProviderPlugin } from "./catalog";
+
+// Describes one custom adapter: the provider identifier it serves, the module
+// specifier to import, and the named export within that module to use as its
+// factory. Specifiers are operator-config-only and resolve to arbitrary code
+// via `import()`, so they must originate solely from trusted operator
+// configuration, never from tenant or deploy data. The shape is validated at
+// every deserialization boundary; the value is trusted operator input.
+export const AdapterManifestEntry = type({
+ provider: ModelProviderPlugin,
+ specifier: "string",
+ export: "string",
+});
+export type AdapterManifestEntry = typeof AdapterManifestEntry.infer;
+
+export const AdapterManifest = AdapterManifestEntry.array();
+export type AdapterManifest = typeof AdapterManifest.infer;
+
+// Parses the raw `SIDECAR_ADAPTER_MANIFEST` environment value. Absent or
+// whitespace-only yields `undefined`; the caller decides what an omitted
+// declaration means at its own boundary. A present value must be a JSON
+// array of manifest entries, and anything else throws naming the variable
+// so a typo fails the boot rather than silently dropping adapters.
+export function parseAdapterManifestEnv(
+ raw: string | undefined,
+): AdapterManifest | undefined {
+ if (raw === undefined || raw.trim() === "") return undefined;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (cause) {
+ throw new Error("SIDECAR_ADAPTER_MANIFEST is not valid JSON", { cause });
+ }
+ const validated = AdapterManifest(parsed);
+ if (validated instanceof type.errors) {
+ throw new Error(
+ `SIDECAR_ADAPTER_MANIFEST failed validation: ${validated.summary}`,
+ );
+ }
+ return validated;
+}
diff --git a/vendor/intx-types/src/agent-address.ts b/vendor/intx-types/src/agent-address.ts
index 56610d931..b08b00a69 100644
--- a/vendor/intx-types/src/agent-address.ts
+++ b/vendor/intx-types/src/agent-address.ts
@@ -5,11 +5,6 @@
// The shape of the right-hand side of the "@" is not validated beyond the
// requirement that it be non-empty: tightening the contract (DNS-ish
// validation, normalisation, etc.) is a separate follow-up.
-//
-// `@intx/hub-sessions`'s `parseAgentId` is the canonical throwing wrapper
-// over `parseRunAddress` — call it when a `null` return would
-// propagate as a silent bug, and keep this parser's `null` return
-// reserved for callers that already have a structured fallback.
const RUN_PREFIX = "run_";
diff --git a/vendor/intx-types/src/attachment-validation.test.ts b/vendor/intx-types/src/attachment-validation.test.ts
new file mode 100644
index 000000000..86a86e614
--- /dev/null
+++ b/vendor/intx-types/src/attachment-validation.test.ts
@@ -0,0 +1,334 @@
+import { describe, test, expect } from "bun:test";
+
+import {
+ DEFAULT_ATTACHMENT_POLICY,
+ validateAttachments,
+ type AttachmentInput,
+ type AttachmentPolicy,
+} from "./attachment-validation";
+import { PER_ATTACHMENT_LIMIT_BYTES } from "./attachments";
+import { base64Encode } from "./base64";
+
+function b64(bytes: number[]): string {
+ return base64Encode(new Uint8Array(bytes));
+}
+
+function bytesOfLength(n: number): string {
+ return base64Encode(new Uint8Array(n).fill(0x61));
+}
+
+// Small limits so oversize cases need only tiny buffers.
+const policy: AttachmentPolicy = {
+ isAllowed: (m) => m === "image/png" || m === "application/pdf",
+ perAttachmentLimitBytes: 100,
+ perMessageTotalLimitBytes: 150,
+ maxAttachments: 3,
+};
+
+describe("validateAttachments", () => {
+ test("rejects too many attachments before decoding any", () => {
+ const inputs = Array.from({ length: 4 }, () => ({
+ mimeType: "image/png",
+ data: "not base64 and never decoded",
+ }));
+ expect(validateAttachments(inputs, policy)).toEqual({
+ ok: false,
+ error: {
+ code: "too_many_attachments",
+ message: "4 attachments, over the limit of 3",
+ count: 4,
+ limit: 3,
+ },
+ });
+ });
+
+ test("accepts valid attachments and defaults names by index", () => {
+ const inputs: AttachmentInput[] = [
+ { mimeType: "image/png", data: b64([1, 2, 3]), name: "shot.png" },
+ { mimeType: "application/pdf", data: b64([4, 5]) },
+ ];
+ const result = validateAttachments(inputs, policy);
+ expect(result).toEqual({
+ ok: true,
+ attachments: [
+ {
+ name: "shot.png",
+ contentType: "image/png",
+ data: new Uint8Array([1, 2, 3]),
+ },
+ {
+ name: "attachment-1",
+ contentType: "application/pdf",
+ data: new Uint8Array([4, 5]),
+ },
+ ],
+ });
+ });
+
+ test("already-decoded bytes pass through the same checks", () => {
+ const bytes = new Uint8Array([1, 2, 3]);
+ expect(
+ validateAttachments([{ mimeType: "image/png", data: bytes }], policy),
+ ).toEqual({
+ ok: true,
+ attachments: [
+ { name: "attachment-0", contentType: "image/png", data: bytes },
+ ],
+ });
+
+ const result = validateAttachments(
+ [{ mimeType: "image/png", data: new Uint8Array(101) }],
+ policy,
+ );
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error.code).toBe("oversize_attachment");
+ });
+
+ test("empty input is valid", () => {
+ expect(validateAttachments([], policy)).toEqual({
+ ok: true,
+ attachments: [],
+ });
+ });
+
+ test("an allowlisted type with parameters is stored as type/subtype", () => {
+ const result = validateAttachments([
+ {
+ mimeType: "text/plain; charset=utf-8",
+ data: new TextEncoder().encode("hi"),
+ name: "notes.txt",
+ },
+ ]);
+ expect(result).toEqual({
+ ok: true,
+ attachments: [
+ {
+ name: "notes.txt",
+ contentType: "text/plain",
+ data: new TextEncoder().encode("hi"),
+ },
+ ],
+ });
+ });
+
+ test("rejects a disallowed mimeType with the offending index", () => {
+ const result = validateAttachments(
+ [
+ { mimeType: "image/png", data: b64([1]) },
+ { mimeType: "image/tiff", data: b64([2]) },
+ ],
+ policy,
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ code: "disallowed_mime_type",
+ attachmentIndex: 1,
+ mimeType: "image/tiff",
+ },
+ });
+ });
+
+ test("rejects a name with header-unsafe characters", () => {
+ const result = validateAttachments(
+ [
+ { mimeType: "image/png", data: b64([1]) },
+ { mimeType: "image/png", data: b64([2]), name: 'a"b.png' },
+ ],
+ policy,
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ error: { code: "invalid_attachment_name", attachmentIndex: 1 },
+ });
+ if (result.ok) return;
+ expect(result.error.message).toContain("invalid characters");
+ });
+
+ test("rejects an empty or whitespace-only name", () => {
+ for (const name of ["", " "]) {
+ const result = validateAttachments(
+ [{ mimeType: "image/png", data: b64([1]), name }],
+ policy,
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ error: { code: "invalid_attachment_name", attachmentIndex: 0 },
+ });
+ if (result.ok) return;
+ expect(result.error.message).toContain("empty name");
+ }
+ });
+
+ test("rejects malformed base64 with the offending index", () => {
+ const result = validateAttachments(
+ [{ mimeType: "image/png", data: "@@not-base64@@" }],
+ policy,
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ error: { code: "malformed_base64", attachmentIndex: 0 },
+ });
+ });
+
+ test("rejects an oversize attachment with byte length and limit", () => {
+ const result = validateAttachments(
+ [{ mimeType: "image/png", data: new Uint8Array(101) }],
+ policy,
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ code: "oversize_attachment",
+ attachmentIndex: 0,
+ byteLength: 101,
+ limitBytes: 100,
+ },
+ });
+ });
+
+ test("accepts compact base64 of exactly the per-attachment limit", () => {
+ const bytes = new Uint8Array(policy.perAttachmentLimitBytes);
+ expect(
+ validateAttachments([{ mimeType: "image/png", data: bytes }], policy).ok,
+ ).toBe(true);
+
+ const encoded = base64Encode(bytes);
+ const result = validateAttachments(
+ [{ mimeType: "image/png", data: encoded }],
+ policy,
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.attachments[0]?.data.length).toBe(
+ policy.perAttachmentLimitBytes,
+ );
+ });
+
+ test("rejects compact base64 one byte over the per-attachment limit", () => {
+ const encoded = base64Encode(
+ new Uint8Array(policy.perAttachmentLimitBytes + 1),
+ );
+ const result = validateAttachments(
+ [{ mimeType: "image/png", data: encoded }],
+ policy,
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ code: "oversize_attachment",
+ attachmentIndex: 0,
+ byteLength: policy.perAttachmentLimitBytes + 1,
+ limitBytes: policy.perAttachmentLimitBytes,
+ },
+ });
+ });
+
+ test("accepts compact base64 of exactly 10MiB under the default policy", () => {
+ const bytes = new Uint8Array(PER_ATTACHMENT_LIMIT_BYTES);
+ const encoded = base64Encode(bytes);
+ const result = validateAttachments([
+ { mimeType: "image/png", data: encoded },
+ ]);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.attachments[0]?.data.length).toBe(
+ DEFAULT_ATTACHMENT_POLICY.perAttachmentLimitBytes,
+ );
+ });
+
+ test("accepts whitespace-padded compact base64 of an in-policy payload", () => {
+ const encoded = ` ${base64Encode(new Uint8Array(policy.perAttachmentLimitBytes))} \n`;
+ const result = validateAttachments(
+ [{ mimeType: "image/png", data: encoded }],
+ policy,
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.attachments[0]?.data.length).toBe(
+ policy.perAttachmentLimitBytes,
+ );
+ });
+
+ test("rejects encoded base64 whose length already exceeds the decoded limit", () => {
+ // 200 alphabet chars → 150 decoded bytes before atob (floor(200/4)*3).
+ const encoded = "x".repeat(200);
+ const result = validateAttachments(
+ [{ mimeType: "image/png", data: encoded }],
+ policy,
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ code: "oversize_attachment",
+ attachmentIndex: 0,
+ byteLength: 150,
+ limitBytes: 100,
+ },
+ });
+ });
+
+ test("rejects an oversize total only after each attachment passes", () => {
+ const result = validateAttachments(
+ [
+ { mimeType: "image/png", data: bytesOfLength(80) },
+ { mimeType: "application/pdf", data: bytesOfLength(80) },
+ ],
+ policy,
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ error: { code: "oversize_total", totalBytes: 160, limitBytes: 150 },
+ });
+ });
+
+ test("per-attachment oversize wins over total (ordering)", () => {
+ // One oversize attachment plus a small one. Per-attachment size is the
+ // most specific error and must win over oversize_total.
+ const result = validateAttachments(
+ [
+ { mimeType: "image/png", data: new Uint8Array(200) },
+ { mimeType: "application/pdf", data: new Uint8Array(10) },
+ ],
+ policy,
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ code: "oversize_attachment",
+ attachmentIndex: 0,
+ byteLength: 200,
+ limitBytes: 100,
+ },
+ });
+ });
+
+ test("returns the first error without decoding later entries", () => {
+ const result = validateAttachments(
+ [
+ { mimeType: "image/tiff", data: b64([1]) },
+ { mimeType: "image/png", data: "x".repeat(200) },
+ ],
+ policy,
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ code: "disallowed_mime_type",
+ attachmentIndex: 0,
+ mimeType: "image/tiff",
+ },
+ });
+ });
+
+ test("structured errors carry a human-readable message", () => {
+ const result = validateAttachments(
+ [{ mimeType: "image/tiff", data: b64([1]) }],
+ policy,
+ );
+ if (result.ok) throw new Error("expected a validation error");
+ expect(typeof result.error.message).toBe("string");
+ expect(result.error.message.length).toBeGreaterThan(0);
+ });
+});
diff --git a/vendor/intx-types/src/attachment-validation.ts b/vendor/intx-types/src/attachment-validation.ts
new file mode 100644
index 000000000..d8db5a9bb
--- /dev/null
+++ b/vendor/intx-types/src/attachment-validation.ts
@@ -0,0 +1,201 @@
+import {
+ isAllowedMimeType,
+ MAX_ATTACHMENTS_PER_MESSAGE,
+ mimeTypeAndSubtype,
+ PER_ATTACHMENT_LIMIT_BYTES,
+ PER_MESSAGE_TOTAL_LIMIT_BYTES,
+} from "./attachments";
+import { base64Decode } from "./base64";
+import type { MessageAttachment } from "./runtime";
+import type { AttachmentError } from "./sessions";
+
+/**
+ * A single attachment as it arrives at a boundary: a MIME type, the bytes,
+ * and an optional filename. A string `data` is base64 (the request-body
+ * form); a caller that already holds the bytes passes them as-is.
+ */
+export type AttachmentInput = {
+ mimeType: string;
+ data: string | Uint8Array;
+ name?: string;
+};
+
+/**
+ * The attachment policy the route validates against. Defaults to the
+ * system-level allowlist and limits; a future per-agent or per-workflow
+ * lookup substitutes a narrowed policy here (the injection seam — no
+ * validation logic changes).
+ */
+export type AttachmentPolicy = {
+ isAllowed: (mimeType: string) => boolean;
+ perAttachmentLimitBytes: number;
+ perMessageTotalLimitBytes: number;
+ maxAttachments: number;
+};
+
+export const DEFAULT_ATTACHMENT_POLICY: AttachmentPolicy = {
+ isAllowed: isAllowedMimeType,
+ perAttachmentLimitBytes: PER_ATTACHMENT_LIMIT_BYTES,
+ perMessageTotalLimitBytes: PER_MESSAGE_TOTAL_LIMIT_BYTES,
+ maxAttachments: MAX_ATTACHMENTS_PER_MESSAGE,
+};
+
+// The error shape is the wire contract `AttachmentError` from @intx/types:
+// a machine-actionable `code`, the fields needed to locate the rejection,
+// and a human-readable `message`. Defining it once in @intx/types lets the
+// route document the structured 400 in its OpenAPI surface.
+export type AttachmentValidationError = AttachmentError;
+
+export type AttachmentValidationResult =
+ | { ok: true; attachments: MessageAttachment[] }
+ | { ok: false; error: AttachmentError };
+
+function compactDecodedSize(compact: string): number {
+ const pad = compact.endsWith("==") ? 2 : compact.endsWith("=") ? 1 : 0;
+ return Math.floor(compact.length / 4) * 3 - pad;
+}
+
+function compactBase64(data: string): string {
+ return data.replace(/\s+/g, "");
+}
+
+function decode(data: string | Uint8Array): Uint8Array | null {
+ if (typeof data !== "string") return data;
+ try {
+ return base64Decode(data);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Validate and decode attachments against a policy at either boundary
+ * (mail tools or the request body).
+ *
+ * The attachment count is checked first, before anything is decoded.
+ * Encoded base64 is size-checked from its compact length (whitespace
+ * stripped, padding counted) before decode, and the first error returns
+ * without decoding later entries. Remaining checks, in encounter order:
+ * encoded oversize, malformed base64, decoded per-attachment oversize,
+ * disallowed MIME type, invalid name. After every attachment passes, the
+ * per-message total is checked. On success the decoded
+ * `MessageAttachment[]` is returned with names defaulted to
+ * `attachment-{index}` by input position.
+ */
+export function validateAttachments(
+ inputs: readonly AttachmentInput[],
+ policy: AttachmentPolicy = DEFAULT_ATTACHMENT_POLICY,
+): AttachmentValidationResult {
+ if (inputs.length > policy.maxAttachments) {
+ return {
+ ok: false,
+ error: {
+ code: "too_many_attachments",
+ message: `${inputs.length} attachments, over the limit of ${policy.maxAttachments}`,
+ count: inputs.length,
+ limit: policy.maxAttachments,
+ },
+ };
+ }
+
+ const decoded: MessageAttachment[] = [];
+
+ for (const [index, input] of inputs.entries()) {
+ let payload: string | Uint8Array;
+ if (typeof input.data === "string") {
+ payload = compactBase64(input.data);
+ const encodedSize = compactDecodedSize(payload);
+ if (encodedSize > policy.perAttachmentLimitBytes) {
+ return {
+ ok: false,
+ error: {
+ code: "oversize_attachment",
+ message: `attachment ${index} is ${encodedSize} bytes, over the ${policy.perAttachmentLimitBytes}-byte limit`,
+ attachmentIndex: index,
+ byteLength: encodedSize,
+ limitBytes: policy.perAttachmentLimitBytes,
+ },
+ };
+ }
+ } else {
+ payload = input.data;
+ }
+
+ const bytes = decode(payload);
+ if (bytes === null) {
+ return {
+ ok: false,
+ error: {
+ code: "malformed_base64",
+ message: `attachment ${index} is not valid base64`,
+ attachmentIndex: index,
+ },
+ };
+ }
+ if (bytes.length > policy.perAttachmentLimitBytes) {
+ return {
+ ok: false,
+ error: {
+ code: "oversize_attachment",
+ message: `attachment ${index} is ${bytes.length} bytes, over the ${policy.perAttachmentLimitBytes}-byte limit`,
+ attachmentIndex: index,
+ byteLength: bytes.length,
+ limitBytes: policy.perAttachmentLimitBytes,
+ },
+ };
+ }
+ const mimeType = mimeTypeAndSubtype(input.mimeType);
+ if (!policy.isAllowed(mimeType)) {
+ return {
+ ok: false,
+ error: {
+ code: "disallowed_mime_type",
+ message: `attachment ${index} has unsupported content type "${input.mimeType}"`,
+ attachmentIndex: index,
+ mimeType: input.mimeType,
+ },
+ };
+ }
+ // A user-supplied name becomes the MIME part's quoted filename, so it
+ // must not contain characters that would break out of the header
+ // (line breaks or a double quote). Empty and whitespace-only names
+ // cannot round-trip through `filename=""`. The default name is always
+ // safe.
+ if (
+ input.name !== undefined &&
+ (input.name.trim() === "" || /[\r\n"]/.test(input.name))
+ ) {
+ const empty = input.name.trim() === "";
+ return {
+ ok: false,
+ error: {
+ code: "invalid_attachment_name",
+ message: empty
+ ? `attachment ${index} has an empty name`
+ : `attachment ${index} has a name with invalid characters (no quotes or line breaks)`,
+ attachmentIndex: index,
+ },
+ };
+ }
+ decoded.push({
+ name: input.name ?? `attachment-${index}`,
+ contentType: mimeType,
+ data: bytes,
+ });
+ }
+
+ const totalBytes = decoded.reduce((sum, a) => sum + a.data.length, 0);
+ if (totalBytes > policy.perMessageTotalLimitBytes) {
+ return {
+ ok: false,
+ error: {
+ code: "oversize_total",
+ message: `attachments total ${totalBytes} bytes, over the ${policy.perMessageTotalLimitBytes}-byte limit`,
+ totalBytes,
+ limitBytes: policy.perMessageTotalLimitBytes,
+ },
+ };
+ }
+
+ return { ok: true, attachments: decoded };
+}
diff --git a/vendor/intx-types/src/attachments.test.ts b/vendor/intx-types/src/attachments.test.ts
index d33329a21..6289db275 100644
--- a/vendor/intx-types/src/attachments.test.ts
+++ b/vendor/intx-types/src/attachments.test.ts
@@ -20,6 +20,14 @@ describe("attachment allowlist", () => {
expect(isAllowedMimeType("")).toBe(false);
});
+ test("Content-Type parameters are not part of allowlist identity", () => {
+ expect(isAllowedMimeType("text/plain; charset=utf-8")).toBe(true);
+ expect(isAllowedMimeType("TEXT/PLAIN; charset=UTF-8")).toBe(true);
+ expect(isAllowedMimeType("image/png; name=shot.png")).toBe(true);
+ expect(isAllowedMimeType("image/tiff; charset=utf-8")).toBe(false);
+ expect(attachmentCategory("text/plain; charset=utf-8")).toBe("document");
+ });
+
test("category dispatch maps each major type and the document category", () => {
expect(attachmentCategory("image/png")).toBe("image");
expect(attachmentCategory("video/mp4")).toBe("video");
diff --git a/vendor/intx-types/src/attachments.ts b/vendor/intx-types/src/attachments.ts
index ffae81706..97f05952f 100644
--- a/vendor/intx-types/src/attachments.ts
+++ b/vendor/intx-types/src/attachments.ts
@@ -38,10 +38,20 @@ export const ATTACHMENT_ALLOWLIST = {
export type AllowedMimeType = keyof typeof ATTACHMENT_ALLOWLIST;
-export function isAllowedMimeType(
- mimeType: string,
-): mimeType is AllowedMimeType {
- return mimeType in ATTACHMENT_ALLOWLIST;
+const ALLOWLIST_BY_TYPE: Record =
+ ATTACHMENT_ALLOWLIST;
+
+/**
+ * The type/subtype of a Content-Type, ignoring parameters and case.
+ * Allowlist identity is this token, not the raw header value.
+ */
+export function mimeTypeAndSubtype(contentType: string): string {
+ const [mimeType = ""] = contentType.split(";");
+ return mimeType.trim().toLowerCase();
+}
+
+export function isAllowedMimeType(mimeType: string): boolean {
+ return Object.hasOwn(ALLOWLIST_BY_TYPE, mimeTypeAndSubtype(mimeType));
}
/**
@@ -53,10 +63,22 @@ export function isAllowedMimeType(
export function attachmentCategory(
mimeType: string,
): AttachmentCategory | undefined {
- if (isAllowedMimeType(mimeType)) {
- return ATTACHMENT_ALLOWLIST[mimeType];
- }
- return undefined;
+ return ALLOWLIST_BY_TYPE[mimeTypeAndSubtype(mimeType)];
+}
+
+/**
+ * True when a Content-Type names readable text: `text/*`, JSON, and the
+ * `+json` structured suffix. Callers use this to decide whether an
+ * attachment's content is carried as text or as opaque bytes. Any
+ * Content-Type parameters (e.g. `; charset=utf-8`) are ignored.
+ */
+export function isTextLikeMimeType(contentType: string): boolean {
+ const mime = mimeTypeAndSubtype(contentType);
+ return (
+ mime.startsWith("text/") ||
+ mime === "application/json" ||
+ mime.endsWith("+json")
+ );
}
// Default size limits, on decoded bytes. These are the system-level
@@ -64,3 +86,7 @@ export function attachmentCategory(
// limit that defaults to these.
export const PER_ATTACHMENT_LIMIT_BYTES = 10 * 1024 * 1024;
export const PER_MESSAGE_TOTAL_LIMIT_BYTES = 30 * 1024 * 1024;
+// Cap on attachment count. The byte limits measure decoded payload, so
+// without this a request could carry an unbounded number of empty
+// attachments, each of which becomes a MIME part.
+export const MAX_ATTACHMENTS_PER_MESSAGE = 20;
diff --git a/vendor/intx-types/src/catalog.test.ts b/vendor/intx-types/src/catalog.test.ts
index 57295fc3c..82912db27 100644
--- a/vendor/intx-types/src/catalog.test.ts
+++ b/vendor/intx-types/src/catalog.test.ts
@@ -11,15 +11,17 @@ import {
PricingRowResponse,
} from "./catalog";
-describe("ModelProviderPlugin enum", () => {
- test("accepts each adapter key", () => {
- for (const plugin of modelProviderPlugins) {
+describe("ModelProviderPlugin", () => {
+ test("accepts every built-in key and an operator-registered key", () => {
+ for (const plugin of [...modelProviderPlugins, "openai-responses"]) {
expect(ModelProviderPlugin(plugin)).toBe(plugin);
}
});
- test("rejects an unknown plugin", () => {
- expect(ModelProviderPlugin("cohere") instanceof type.errors).toBe(true);
+ test("rejects a key the grant identifier cannot delimit", () => {
+ for (const plugin of ["", " ", "a:b", "Anthropic", "-x", "a/b"]) {
+ expect(ModelProviderPlugin(plugin) instanceof type.errors).toBe(true);
+ }
});
});
diff --git a/vendor/intx-types/src/catalog.ts b/vendor/intx-types/src/catalog.ts
index 47d1c3717..fadfbc60a 100644
--- a/vendor/intx-types/src/catalog.ts
+++ b/vendor/intx-types/src/catalog.ts
@@ -8,12 +8,16 @@ export const modelProviderPlugins = [
"openai-compatible",
"google-genai",
] as const;
-export type ModelProviderPlugin = (typeof modelProviderPlugins)[number];
-export const ModelProviderPlugin = type
- .enumerated(...modelProviderPlugins)
- .describe(
- "The inference adapter that serves this provider's models, dispatched by the runtime provider registry.",
- );
+// Open-ended so an operator-registered adapter (SIDECAR_ADAPTER_MANIFEST) is
+// selectable; a key no sidecar registers fails at deploy admission. The
+// grammar excludes `:` because the key is embedded in the
+// `inference.source::` grant identifier, which is matched as
+// one string: a colon in the plugin would let two provider/model pairs share
+// an approval.
+export const ModelProviderPlugin = type(/^[a-z0-9][a-z0-9._-]*$/).describe(
+ "The inference adapter that serves this provider's models, dispatched by the runtime provider registry: a built-in key or one registered in the sidecar's adapter manifest. Lowercase alphanumerics, `.`, `_` and `-`, starting with an alphanumeric.",
+);
+export type ModelProviderPlugin = typeof ModelProviderPlugin.infer;
export const providerPreferenceModes = ["pin", "prefer"] as const;
export type ProviderPreferenceMode = (typeof providerPreferenceModes)[number];
diff --git a/vendor/intx-types/src/credential-delivery.ts b/vendor/intx-types/src/credential-delivery.ts
new file mode 100644
index 000000000..398824a85
--- /dev/null
+++ b/vendor/intx-types/src/credential-delivery.ts
@@ -0,0 +1,37 @@
+import { type } from "arktype";
+
+/**
+ * The decrypted credential material and per-handle binding descriptors
+ * delivered to a running agent so its tools can use provider-backed
+ * credentials. Secrets are decrypted hub-side and ride this payload on the
+ * live channel ONLY -- the deploy frame at launch, a `credentials.update`
+ * frame on rotation, and the child's in-memory cell. They are NEVER written to
+ * disk (they do not ride the git-committed grants file) and NEVER copied into
+ * any snapshot, event, or state -- redaction is by construction, mirroring how
+ * an `InferenceSource`'s `apiKey` stays off every egress type.
+ *
+ * `materials` is keyed by `credentialId` (a credential can back several handles,
+ * so its secret is stored once); `bindings` maps each declared tool handle to
+ * the credential that backs it and the consumer identity allowed to use it.
+ */
+export const CredentialMaterialEntry = type({
+ credentialId: "string",
+ providerKey: "string",
+ origin: "string",
+ secret: "string",
+});
+export type CredentialMaterialEntry = typeof CredentialMaterialEntry.infer;
+
+export const CredentialBindingDescriptor = type({
+ handle: "string",
+ credentialId: "string",
+ consumer: "string",
+});
+export type CredentialBindingDescriptor =
+ typeof CredentialBindingDescriptor.infer;
+
+export const CredentialDelivery = type({
+ bindings: CredentialBindingDescriptor.array(),
+ materials: CredentialMaterialEntry.array(),
+});
+export type CredentialDelivery = typeof CredentialDelivery.infer;
diff --git a/vendor/intx-types/src/credentials.ts b/vendor/intx-types/src/credentials.ts
index aad64b732..7a537f38f 100644
--- a/vendor/intx-types/src/credentials.ts
+++ b/vendor/intx-types/src/credentials.ts
@@ -45,7 +45,7 @@ const BindingLocator = type.enumerated(...credentialBindingLocators);
export const CredentialBinding = type({
package: type("string").describe(
- "The tool package the declared handle belongs to; matches the resolved manifest's top-level package name.",
+ "The tool package the declared handle belongs to. On a source-ref workflow this field is the tool factory's bundle id. On a pinned package it is that package's name in the tool-package manifest's top level.",
),
handle: ToolCredentialHandle.describe(
"The credential handle the tool package declared; unique within its package.",
diff --git a/vendor/intx-types/src/format-safety-rating.ts b/vendor/intx-types/src/format-safety-rating.ts
new file mode 100644
index 000000000..5369f8200
--- /dev/null
+++ b/vendor/intx-types/src/format-safety-rating.ts
@@ -0,0 +1,11 @@
+import type { SafetyRatingBlock } from "./runtime-core";
+
+/**
+ * Human-readable rendering of a SafetyRatingBlock for reply text,
+ * timeline summaries, and request-history rewrites when a provider
+ * has no input wire shape for safety_rating. Single owner of the
+ * display string so reply / history / transform stay in lockstep.
+ */
+export function formatSafetyRatingText(block: SafetyRatingBlock): string {
+ return `Request blocked: ${block.blockReason}`;
+}
diff --git a/vendor/intx-types/src/inbound-mail-policy.ts b/vendor/intx-types/src/inbound-mail-policy.ts
new file mode 100644
index 000000000..f65467aef
--- /dev/null
+++ b/vendor/intx-types/src/inbound-mail-policy.ts
@@ -0,0 +1,91 @@
+import { type } from "arktype";
+
+/**
+ * The admission outcome of an inbound message. This is the single vocabulary a
+ * delivery decision keys on, distinct from the two-axis signature verdict that
+ * produces it.
+ *
+ * - `clean` — nothing suspect; always admitted
+ * - `untrustedFrom` — the visible `From` is present but cannot be reduced to a
+ * single addr-spec
+ * - `mismatchedFrom` — the visible `From` names a different identity from the
+ * sender the hub stamped
+ * - `absentFrom` — the message carries no usable `From`
+ * - `invalid` — the signature check failed (tampering or the wrong key)
+ * - `missing` — the message carried no signature
+ * - `unknown` — no key was available to verify against
+ * - `error` — a fault stopped the check from running at all; always rejected
+ */
+export const InboundMailOutcome = type.enumerated(
+ "clean",
+ "untrustedFrom",
+ "mismatchedFrom",
+ "absentFrom",
+ "invalid",
+ "missing",
+ "unknown",
+ "error",
+);
+export type InboundMailOutcome = typeof InboundMailOutcome.infer;
+
+/**
+ * The subset of {@link InboundMailOutcome} a workflow author may relax to admit
+ * a message that would otherwise be rejected. It omits `clean` (which always
+ * admits, so there is nothing to relax) and `error` (pinned to reject, since a
+ * fault we could not check through is never something an author should be able
+ * to wave past). A per-workflow policy keys on exactly these outcomes.
+ */
+export const AuthorControllableOutcome = type.enumerated(
+ "untrustedFrom",
+ "mismatchedFrom",
+ "absentFrom",
+ "invalid",
+ "missing",
+ "unknown",
+);
+export type AuthorControllableOutcome = typeof AuthorControllableOutcome.infer;
+
+/**
+ * A per-workflow inbound-mail admission policy: for each admission outcome the
+ * author may control, whether a message that raises that outcome as a finding is
+ * `reject`ed or `admit`ted. The key set is exactly the
+ * {@link AuthorControllableOutcome} values -- `clean` (always admitted) and
+ * `error` (pinned to reject) are deliberately not keys.
+ *
+ * The object is SPARSE: every key is optional, and an omitted key is NOT a
+ * default of any kind here. It is left for a later resolution step to interpret
+ * an absent outcome. Keeping it sparse means the content hash covers only what
+ * the author actually declared, so a definition that omits the policy hashes
+ * identically to one authored before the field existed. Undeclared keys are
+ * rejected so a typo such as `clean` or `errror` fails at the wire boundary
+ * rather than riding through as an inert unknown key.
+ */
+export const InboundMailPolicy = type({
+ "untrustedFrom?": "'reject' | 'admit'",
+ "mismatchedFrom?": "'reject' | 'admit'",
+ "absentFrom?": "'reject' | 'admit'",
+ "invalid?": "'reject' | 'admit'",
+ "missing?": "'reject' | 'admit'",
+ "unknown?": "'reject' | 'admit'",
+}).onUndeclaredKey("reject");
+export type InboundMailPolicy = typeof InboundMailPolicy.infer;
+
+type AssertEqual = [A] extends [B]
+ ? [B] extends [A]
+ ? true
+ : false
+ : false;
+
+/**
+ * The three outcome vocabularies must move together, but nothing else connects
+ * them: each spells the same names out again in its own notation.
+ */
+const _policyKeysMatchAuthorControllable: AssertEqual<
+ keyof InboundMailPolicy,
+ AuthorControllableOutcome
+> = true;
+
+const _authorControllableAreOutcomes: AssertEqual<
+ Exclude,
+ never
+> = true;
diff --git a/vendor/intx-types/src/index.ts b/vendor/intx-types/src/index.ts
index 755229df8..5ae3df72f 100644
--- a/vendor/intx-types/src/index.ts
+++ b/vendor/intx-types/src/index.ts
@@ -9,6 +9,7 @@ export * from "./signals";
export * from "./instances";
export * from "./workflows";
export * from "./attachments";
+export * from "./attachment-validation";
export * from "./sessions";
export * from "./approvals";
export * from "./wallets";
@@ -23,6 +24,7 @@ export * from "./offerings";
export * from "./models";
export * from "./capabilities";
export * from "./catalog";
+export * from "./adapter-manifest";
export * from "./observability";
export * from "./agent-address";
export * from "./agent-data";
@@ -36,3 +38,4 @@ export * from "./has-code";
export * from "./audit";
export * from "./sidecar-allocation";
export * from "./sidecar-capabilities";
+export * from "./workflow-lifecycle";
diff --git a/vendor/intx-types/src/inference-events.ts b/vendor/intx-types/src/inference-events.ts
new file mode 100644
index 000000000..6bcc006c8
--- /dev/null
+++ b/vendor/intx-types/src/inference-events.ts
@@ -0,0 +1,619 @@
+import { type } from "arktype";
+import {
+ PartialMessage,
+ RedactedThinkingBlock,
+ TokenUsage,
+ LastCycleSource,
+ AssistantTurn,
+ InferenceError,
+ CitationBlock,
+ SafetyRatingBlock,
+ CodeExecutionRequestBlock,
+ CodeExecutionResultBlock,
+ ImageBlock,
+ ToolCall,
+ ToolResult,
+ GateType,
+ ApprovalSnapshot,
+ ForkMode,
+ type InboundMessage,
+} from "./runtime-core";
+
+// ---------------------------------------------------------------------------
+// Inference Event Protocol (INFERENCE.md § Event Protocol)
+// ---------------------------------------------------------------------------
+
+/**
+ * Wire-safe representation of InboundMessage for use in InferenceEvent
+ * variants. The runtime InboundMessage type contains Uint8Array fields
+ * (MessageAttachment.data) that cannot survive JSON serialization, so the
+ * wire validator uses `unknown` for attachment data and accepts whatever
+ * JSON.parse produces.
+ */
+const WireInboundMessage = type({
+ ref: { uid: "number", mailbox: "string" },
+ headers: "Record",
+ flags: "string[]",
+ "content?": "string",
+ "payload?": "object",
+ "attachments?": "unknown[]",
+ signatureStatus: type.enumerated("valid", "invalid", "unknown", "missing"),
+});
+
+/**
+ * A single event in the inference event protocol. Every event carries a
+ * monotonic session-scoped sequence number.
+ *
+ * Event types are namespaced: `inference.*`, `tool.*`, `reactor.*`,
+ * `fork.*`, `message.*`, `custom.*`.
+ *
+ * (INFERENCE.md § Event Protocol)
+ */
+export const InferenceEvent = type.or(
+ {
+ type: "'inference.start'",
+ seq: "number",
+ data: { model: "string" },
+ },
+ {
+ type: "'inference.thinking.delta'",
+ seq: "number",
+ data: {
+ token: "string",
+ partial: PartialMessage,
+ "index?": "number",
+ },
+ },
+ {
+ type: "'inference.block.signature'",
+ seq: "number",
+ data: { signature: "string", "index?": "number" },
+ },
+ {
+ type: "'inference.thinking.redacted'",
+ seq: "number",
+ data: { redactedThinking: RedactedThinkingBlock, "index?": "number" },
+ },
+ {
+ type: "'inference.text.delta'",
+ seq: "number",
+ data: {
+ token: "string",
+ partial: PartialMessage,
+ "index?": "number",
+ },
+ },
+ {
+ type: "'inference.refusal.delta'",
+ seq: "number",
+ data: {
+ token: "string",
+ partial: PartialMessage,
+ "index?": "number",
+ },
+ },
+ {
+ type: "'inference.tool_call.start'",
+ seq: "number",
+ data: {
+ callId: "string",
+ name: "string",
+ partial: PartialMessage,
+ "index?": "number",
+ },
+ },
+ {
+ type: "'inference.tool_call.delta'",
+ seq: "number",
+ data: {
+ callId: "string",
+ argumentFragment: "string",
+ partial: PartialMessage,
+ "index?": "number",
+ },
+ },
+ {
+ type: "'inference.tool_call.end'",
+ seq: "number",
+ data: {
+ callId: "string",
+ name: "string",
+ arguments: "Record",
+ partial: PartialMessage,
+ "index?": "number",
+ },
+ },
+ {
+ type: "'inference.usage'",
+ seq: "number",
+ // Locally patched — see vendor/intx-types/PATCHES.md#types-ts-usage-stop-reason
+ data: { usage: TokenUsage, source: LastCycleSource, "stopReason?": "string" },
+ },
+ {
+ type: "'inference.done'",
+ seq: "number",
+ data: {
+ turn: AssistantTurn,
+ usage: TokenUsage,
+ source: LastCycleSource,
+ "pacingDelayMs?": "number",
+ },
+ },
+ {
+ type: "'inference.error'",
+ seq: "number",
+ data: { error: InferenceError, partial: PartialMessage },
+ },
+ {
+ type: "'inference.retry'",
+ seq: "number",
+ data: {
+ attempt: "number",
+ delayMs: "number",
+ previousError: InferenceError,
+ },
+ },
+ {
+ type: "'inference.citation'",
+ seq: "number",
+ // `index`, when present, names the source content block (typically
+ // a TextBlock) the citation annotates. The harness uses it to
+ // interleave the citation into the finalized turn's `content[]`
+ // immediately after the matching block. Adapters whose wire
+ // protocol does not carry per-citation block indices omit the
+ // field; the harness then appends those citations at the end of
+ // `content[]` and consumers attribute them to the nearest
+ // preceding TextBlock per the CitationBlock docstring.
+ data: { citation: CitationBlock, "index?": "number" },
+ },
+ {
+ type: "'inference.safety_rating'",
+ seq: "number",
+ // Prompt-level structured safety signal (observed Gemini
+ // `promptFeedback.blockReason`). No candidate index: the first
+ // capture has zero candidates. Harness appends the block to the
+ // finalized turn's `content[]`.
+ data: { safetyRating: SafetyRatingBlock },
+ },
+ {
+ type: "'inference.code_execution.start'",
+ seq: "number",
+ data: { request: CodeExecutionRequestBlock, "index?": "number" },
+ },
+ {
+ type: "'inference.code_execution.delta'",
+ seq: "number",
+ // requestId correlates fragments back to the originating
+ // CodeExecutionRequestBlock; index is the positional hint into
+ // the response's content-block stream. They are independent: a
+ // single response may stream code execution for multiple
+ // requests interleaved, distinguished by requestId; index lets
+ // the harness's per-block accumulator route the fragment to
+ // the correct block when the array isn't yet finalized.
+ data: {
+ requestId: "string",
+ codeFragment: "string",
+ "index?": "number",
+ },
+ },
+ {
+ type: "'inference.code_execution.result'",
+ seq: "number",
+ data: { result: CodeExecutionResultBlock, "index?": "number" },
+ },
+ {
+ type: "'inference.image_output'",
+ seq: "number",
+ // Fires mid-stream when an adapter finalizes an image-output
+ // block, signaling that the image is ready for downstream
+ // handoff before the full inference.done lands. The wrapped
+ // ImageBlock typically carries a base64 MediaSource — the
+ // payload can be large (Gemini's image-output captures show
+ // ~1MB inline blobs); consumers that subscribe to this event
+ // should treat it as a non-trivial transport size.
+ data: { image: ImageBlock, "index?": "number" },
+ },
+ {
+ type: "'tool.start'",
+ seq: "number",
+ data: { call: ToolCall },
+ },
+ {
+ type: "'tool.update'",
+ seq: "number",
+ data: { callId: "string", partial: "string" },
+ },
+ {
+ type: "'tool.done'",
+ seq: "number",
+ data: { result: ToolResult },
+ },
+ {
+ type: "'message.queued'",
+ seq: "number",
+ data: { message: WireInboundMessage },
+ },
+ {
+ type: "'message.run.started'",
+ seq: "number",
+ data: {
+ "messageId?": "string",
+ messageRunId: "string",
+ receivedAt: "number",
+ },
+ },
+ {
+ type: "'message.run.ended'",
+ seq: "number",
+ data: {
+ messageRunId: "string",
+ "messageId?": "string",
+ status: type.enumerated("completed", "failed"),
+ "error?": {
+ message: "string",
+ "kind?": "string",
+ },
+ },
+ },
+ {
+ type: "'message.correlated'",
+ seq: "number",
+ data: { message: WireInboundMessage, correlationId: "string" },
+ },
+ {
+ type: "'connector.reply'",
+ seq: "number",
+ data: { content: "string", "checkpointHash?": "string" },
+ },
+ {
+ type: "'reactor.start'",
+ seq: "number",
+ data: "object",
+ },
+ {
+ type: "'reactor.gate.blocked'",
+ seq: "number",
+ data: {
+ reason: GateType,
+ gateId: "string",
+ "correlationId?": "string",
+ "approvalSnapshot?": ApprovalSnapshot,
+ },
+ },
+ {
+ type: "'reactor.gate.cleared'",
+ seq: "number",
+ data: {
+ gateId: "string",
+ reason: type.enumerated("resolved", "timeout", "shutdown"),
+ },
+ },
+ {
+ type: "'reactor.done'",
+ seq: "number",
+ data: "object",
+ },
+ {
+ type: "'reactor.error'",
+ seq: "number",
+ data: { error: "string", fatal: "boolean" },
+ },
+ {
+ type: "'fork.created'",
+ seq: "number",
+ data: { forkId: "string", parentId: "string", mode: ForkMode },
+ },
+ {
+ type: "'fork.done'",
+ seq: "number",
+ data: { forkId: "string", "result?": "unknown" },
+ },
+ {
+ type: "'fork.error'",
+ seq: "number",
+ data: { forkId: "string", error: "string" },
+ },
+ {
+ type: "'fork.aborted'",
+ seq: "number",
+ data: { forkId: "string" },
+ },
+ {
+ type: /^custom\./,
+ seq: "number",
+ data: "Record",
+ },
+);
+// The TypeScript type is defined manually rather than inferred from the
+// validator because the `custom.*` variant uses a regex pattern which
+// arktype infers as `string`. A bare `string` in the discriminant position
+// prevents TypeScript from narrowing the union in switch statements.
+// The manually defined type uses a `custom.${string}` template literal
+// for that variant, preserving the narrowing behavior downstream code
+// relies on.
+export type InferenceEvent =
+ | { type: "inference.start"; seq: number; data: { model: string } }
+ | {
+ type: "inference.thinking.delta";
+ seq: number;
+ data: { token: string; partial: PartialMessage; index?: number };
+ }
+ | {
+ type: "inference.block.signature";
+ seq: number;
+ data: { signature: string; index?: number };
+ }
+ | {
+ type: "inference.thinking.redacted";
+ seq: number;
+ data: { redactedThinking: RedactedThinkingBlock; index?: number };
+ }
+ | {
+ type: "inference.text.delta";
+ seq: number;
+ data: { token: string; partial: PartialMessage; index?: number };
+ }
+ | {
+ type: "inference.refusal.delta";
+ seq: number;
+ data: { token: string; partial: PartialMessage; index?: number };
+ }
+ | {
+ type: "inference.tool_call.start";
+ seq: number;
+ data: {
+ callId: string;
+ name: string;
+ partial: PartialMessage;
+ index?: number;
+ };
+ }
+ | {
+ type: "inference.tool_call.delta";
+ seq: number;
+ data: {
+ callId: string;
+ argumentFragment: string;
+ partial: PartialMessage;
+ index?: number;
+ };
+ }
+ | {
+ type: "inference.tool_call.end";
+ seq: number;
+ data: {
+ callId: string;
+ name: string;
+ arguments: Record;
+ partial: PartialMessage;
+ index?: number;
+ };
+ }
+ | {
+ type: "inference.usage";
+ seq: number;
+ // Locally patched — see vendor/intx-types/PATCHES.md#types-ts-usage-stop-reason
+ data: { usage: TokenUsage; source: LastCycleSource; stopReason?: string };
+ }
+ | {
+ type: "inference.done";
+ seq: number;
+ data: {
+ turn: AssistantTurn;
+ usage: TokenUsage;
+ source: LastCycleSource;
+ pacingDelayMs?: number;
+ };
+ }
+ | {
+ type: "inference.error";
+ seq: number;
+ data: { error: InferenceError; partial: PartialMessage };
+ }
+ | {
+ /**
+ * Emitted between attempts when the per-call retry policy decides
+ * to retry after an error. `attempt` is the 1-indexed number of
+ * the attempt that just **failed** — the same value the policy
+ * saw on its `RetrySituation.attempt` reading. `delayMs` is the
+ * delay the wrapper will apply before the next attempt starts;
+ * `previousError` carries the classified error that triggered
+ * the retry. The event is not emitted when the policy aborts.
+ */
+ type: "inference.retry";
+ seq: number;
+ data: {
+ attempt: number;
+ delayMs: number;
+ previousError: InferenceError;
+ };
+ }
+ | {
+ type: "inference.citation";
+ seq: number;
+ data: { citation: CitationBlock; index?: number };
+ }
+ | {
+ type: "inference.safety_rating";
+ seq: number;
+ data: { safetyRating: SafetyRatingBlock };
+ }
+ | {
+ type: "inference.code_execution.start";
+ seq: number;
+ data: { request: CodeExecutionRequestBlock; index?: number };
+ }
+ | {
+ type: "inference.code_execution.delta";
+ seq: number;
+ data: { requestId: string; codeFragment: string; index?: number };
+ }
+ | {
+ type: "inference.code_execution.result";
+ seq: number;
+ data: { result: CodeExecutionResultBlock; index?: number };
+ }
+ | {
+ type: "inference.image_output";
+ seq: number;
+ data: { image: ImageBlock; index?: number };
+ }
+ | { type: "tool.start"; seq: number; data: { call: ToolCall } }
+ | {
+ type: "tool.update";
+ seq: number;
+ data: { callId: string; partial: string };
+ }
+ | { type: "tool.done"; seq: number; data: { result: ToolResult } }
+ | {
+ type: "message.queued";
+ seq: number;
+ data: { message: InboundMessage };
+ }
+ | {
+ /**
+ * Per-message run-bracket open. Emitted by the reactor when it
+ * dequeues an inbound mail message and begins per-message work.
+ *
+ * `messageRunId` is reactor-minted, unique per dequeue. It is
+ * non-negotiable for crash-replay correlation: the reactor can
+ * legitimately dequeue the same `messageId` more than once across
+ * a crash + replay cycle, so two bracket-open events with the
+ * same `messageId` and no run-id cannot be unambiguously paired
+ * with their `message.run.ended` counterparts.
+ */
+ type: "message.run.started";
+ seq: number;
+ data: {
+ messageId?: string;
+ messageRunId: string;
+ receivedAt: number;
+ };
+ }
+ | {
+ /**
+ * Per-message run-bracket close. Pairs with `message.run.started`
+ * by `messageRunId`. `messageId` is carried redundantly so log
+ * readers can correlate without a join against the open event; it is
+ * absent for a message that named no id of its own.
+ *
+ * The `status` enum is `"completed" | "failed"` only.
+ * Cancellation lives in the workflow-runtime's
+ * `CancelRequested` -> `RunFailed` vocabulary, not on the
+ * reactor's bracket: the reactor does not run a state machine
+ * and what it observes when cancellation arrives is a harness
+ * abort, which is structurally `"failed"` with a specific
+ * `error.kind`.
+ *
+ * `error.kind` is documented as one of
+ * `"inference_error" | "tool_error" | "reactor_fatal" |
+ * "harness_aborted" | "doom_loop"` initially, extensible as new
+ * failure categories surface. `"doom_loop"` marks a protective
+ * break the reactor took on the agent's behalf when the agent
+ * repeated an identical tool batch past the configured threshold;
+ * unlike `"reactor_fatal"` it is not an internal fault.
+ */
+ type: "message.run.ended";
+ seq: number;
+ data: {
+ messageRunId: string;
+ messageId?: string;
+ status: "completed" | "failed";
+ error?: {
+ message: string;
+ kind?: string;
+ };
+ };
+ }
+ | {
+ type: "message.correlated";
+ seq: number;
+ data: { message: InboundMessage; correlationId: string };
+ }
+ | {
+ type: "connector.reply";
+ seq: number;
+ data: { content: string; checkpointHash?: string };
+ }
+ | { type: "reactor.start"; seq: number; data: Record }
+ | {
+ type: "reactor.gate.blocked";
+ seq: number;
+ data: {
+ reason: GateType;
+ gateId: string;
+ correlationId?: string;
+ approvalSnapshot?: ApprovalSnapshot;
+ };
+ }
+ | {
+ type: "reactor.gate.cleared";
+ seq: number;
+ data: {
+ gateId: string;
+ reason: "resolved" | "timeout" | "shutdown";
+ };
+ }
+ | { type: "reactor.done"; seq: number; data: Record }
+ | {
+ type: "reactor.error";
+ seq: number;
+ data: { error: string; fatal: boolean };
+ }
+ | {
+ type: "fork.created";
+ seq: number;
+ data: { forkId: string; parentId: string; mode: ForkMode };
+ }
+ | {
+ type: "fork.done";
+ seq: number;
+ data: { forkId: string; result?: unknown };
+ }
+ | {
+ type: "fork.error";
+ seq: number;
+ data: { forkId: string; error: string };
+ }
+ | { type: "fork.aborted"; seq: number; data: { forkId: string } }
+ | {
+ type: `custom.${string}`;
+ seq: number;
+ data: Record;
+ };
+
+// Load-bearing drift guards for the dual-maintained `reactor.gate.blocked`
+// event. The arktype `InferenceEvent` validator and the hand-written
+// `InferenceEvent` type are kept in lockstep by hand (the `custom.*` regex
+// variant forces the manual mirror). arktype passes undeclared keys through at
+// runtime, so a schema that dropped `approvalSnapshot` would not fail at
+// runtime. Projecting the field off each inferred shape makes it load-bearing:
+// `tsc` errors if either mirror stops carrying it, mirroring the
+// `_persistedSuspendedCall` guard in storage-isogit.
+const _arkGateBlockedApprovalSnapshot = (
+ data: Extract<
+ typeof InferenceEvent.infer,
+ { type: "reactor.gate.blocked" }
+ >["data"],
+): ApprovalSnapshot | undefined => data.approvalSnapshot;
+void _arkGateBlockedApprovalSnapshot;
+
+const _tsGateBlockedApprovalSnapshot = (
+ data: Extract["data"],
+): ApprovalSnapshot | undefined => data.approvalSnapshot;
+void _tsGateBlockedApprovalSnapshot;
+
+/**
+ * Validate unknown data as an InferenceEvent. ArkType's regex-based validator
+ * infers `custom.*` event types as `string`, but the manual InferenceEvent type
+ * uses a `custom.${string}` template literal for switch narrowing. This function
+ * centralizes that single unavoidable cast.
+ */
+export function parseInferenceEvent(
+ data: unknown,
+): InferenceEvent | type.errors {
+ const result = InferenceEvent(data);
+ if (result instanceof type.errors) return result;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- arktype regex infers as string; manual type uses template literal
+ return result as InferenceEvent;
+}
diff --git a/vendor/intx-types/src/instances.ts b/vendor/intx-types/src/instances.ts
index 9f2786ceb..2b33e5059 100644
--- a/vendor/intx-types/src/instances.ts
+++ b/vendor/intx-types/src/instances.ts
@@ -48,6 +48,12 @@ export const WorkflowRunResponse = type({
createdAt: "string",
updatedAt: "string",
"endedAt?": "string | null",
+ "failureCode?": type("string | null").describe(
+ "Why the Hub failed this run's deployment, such as `sidecar_connect_failed`; null for a run the Hub did not fail.",
+ ),
+ "failureMessage?": type("string | null").describe(
+ "The detail of `failureCode`, such as the error the sidecar reported.",
+ ),
});
export const WorkflowRunHealth = type({
diff --git a/vendor/intx-types/src/message-id.test.ts b/vendor/intx-types/src/message-id.test.ts
index 10a574eb6..8111748bb 100644
--- a/vendor/intx-types/src/message-id.test.ts
+++ b/vendor/intx-types/src/message-id.test.ts
@@ -20,16 +20,67 @@ describe("deriveMessageId", () => {
test("is case-insensitive on the header name", async () => {
const raw = encoder.encode(
- ["message-id: ", "", "body"].join("\n"),
+ ["message-id: ", "", "body"].join("\r\n"),
);
expect(await deriveMessageId(raw)).toBe("");
});
- test("tolerates a lone-LF header boundary", async () => {
+ test("names no id in a lone-LF message, taking the digest instead", async () => {
+ // CRLF is the sole line terminator (RFC 5321 section 2.3.8, section
+ // 4.1.1.4), so a lone LF ends no field and `@intx/mime` refuses the
+ // message outright. Reading an id out of that text would give the two
+ // parsers different answers about the same bytes.
const raw = encoder.encode(
["Message-ID: ", "", "body"].join("\n"),
);
- expect(await deriveMessageId(raw)).toBe("");
+ expect(parseMessageIdHeader(raw)).toBeNull();
+ expect(await deriveMessageId(raw)).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ test("resolves no id from a bare break inside a field body", () => {
+ // The smuggled field would otherwise name the id for a message whose real
+ // `Message-ID` is the one the sender wrote above it.
+ const raw = encoder.encode(
+ "From: a@example.com\nMessage-ID: \r\n\r\nbody",
+ );
+ expect(parseMessageIdHeader(raw)).toBeNull();
+ });
+
+ test("names no id for an LF-only header section, whatever ends it", async () => {
+ // A whole-message search for the `CRLF CRLF` separator finds the one a
+ // sender left in the body and reads the header section from there, so the id
+ // absorbs the fields after the first and the head of the body. This id is
+ // the claim-check dedup key, a stored filename and a database join value, so
+ // no body bytes may reach it.
+ const bodyTerminated = encoder.encode(
+ "Message-ID: \nSubject: Hi\n\nbody\r\n\r\ntail\n",
+ );
+ expect(parseMessageIdHeader(bodyTerminated)).toBeNull();
+ expect(await deriveMessageId(bodyTerminated)).toMatch(/^[0-9a-f]{64}$/);
+
+ const crlfTerminated = encoder.encode(
+ "Message-ID: \nSubject: Hi\r\n\r\nbody",
+ );
+ expect(parseMessageIdHeader(crlfTerminated)).toBeNull();
+ expect(await deriveMessageId(crlfTerminated)).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ test("names no id when an LF LF ends the header section", () => {
+ // The blank line is a pair of line breaks of its own. Honouring a bare pair
+ // lets a sender who controls one field body end the section early and strip
+ // the `Message-ID` after it.
+ const raw = encoder.encode(
+ "From: a@example.com\r\nSubject: Hi\n\n" +
+ "Message-ID: \r\n\r\nbody",
+ );
+ expect(parseMessageIdHeader(raw)).toBeNull();
+ });
+
+ test("reads the id at the first blank line when the body carries another", () => {
+ const raw = encoder.encode(
+ "Message-ID: \r\n\r\nbody\r\n\r\ntail\r\n",
+ );
+ expect(parseMessageIdHeader(raw)).toBe("");
});
test("falls back to a sha256 hex digest with no Message-ID header", async () => {
@@ -45,4 +96,33 @@ describe("deriveMessageId", () => {
const raw = encoder.encode("From: a@example.com\r\n\r\nbody");
expect(parseMessageIdHeader(raw)).toBeNull();
});
+
+ test("treats a blank Message-ID header as naming no id", () => {
+ // RFC 2822 defines `Message-ID` as `msg-id`, which admits no empty value,
+ // so a header a sender left blank names nothing.
+ const raw = encoder.encode(
+ ["From: a@example.com", "Message-ID: ", "", "body"].join("\r\n"),
+ );
+ expect(parseMessageIdHeader(raw)).toBeNull();
+ });
+
+ test("two different messages with blank Message-ID headers derive different ids", async () => {
+ // The derived id is the dedup key that makes the same bytes consume once.
+ // Answering `""` for a blank header would make it one key two different
+ // messages share, so the second would be discarded as a redelivery of the
+ // first. Each falls back to its own digest instead.
+ const first = encoder.encode(
+ ["From: a@example.com", "Message-ID:", "", "first body"].join("\r\n"),
+ );
+ const second = encoder.encode(
+ ["From: b@example.com", "Message-ID:", "", "second body"].join("\r\n"),
+ );
+
+ const firstId = await deriveMessageId(first);
+ const secondId = await deriveMessageId(second);
+
+ expect(firstId).toMatch(/^[0-9a-f]{64}$/);
+ expect(secondId).toMatch(/^[0-9a-f]{64}$/);
+ expect(firstId).not.toBe(secondId);
+ });
});
diff --git a/vendor/intx-types/src/message-id.ts b/vendor/intx-types/src/message-id.ts
index 523afffe8..cd96d94cf 100644
--- a/vendor/intx-types/src/message-id.ts
+++ b/vendor/intx-types/src/message-id.ts
@@ -18,10 +18,15 @@
import { hexEncode } from "./hex";
+const BARE_LINE_BREAK = /\r(?!\n)|(? {
const messageIdFromHeader = parseMessageIdHeader(rawMessage);
@@ -38,30 +43,32 @@ export async function deriveMessageId(rawMessage: Uint8Array): Promise {
/**
* Parse the `Message-ID` header value from a raw message, or `null` when
- * the message carries no such header.
+ * the message carries no such header or carries a blank one.
*
- * The parser walks the message until the headers/body separator
- * (`CRLF CRLF` per RFC 2822 §2.1, with the lone-`LF` variant tolerated to
- * match common in-memory senders). Header-field unfolding follows RFC
- * 2822 §2.2.3: a continuation line begins with whitespace and appends to
- * the prior line. Header-name comparison is case-insensitive per RFC 2822
- * §1.2.2.
+ * The header section ends at the first blank line, whichever line-break flavour
+ * wrote it, and the whole message is the section when it carries no blank line.
+ * CRLF is the sole line terminator (RFC 5321 §2.3.8, §4.1.1.4), matching
+ * `@intx/mime`'s decoder, which refuses a section any other break wrote -- an
+ * `LF LF` separator included. Such a message names no id here and takes the
+ * digest in `deriveMessageId` instead, because reading one out of text that
+ * parser refuses would fold the fields after the break, and the head of the
+ * body, into this identifier. Header-field unfolding follows RFC 2822 §2.2.3: a
+ * continuation line begins with whitespace and appends to the prior line.
+ * Header-name comparison is case-insensitive per RFC 2822 §1.2.2.
*/
export function parseMessageIdHeader(rawMessage: Uint8Array): string | null {
- const text = new TextDecoder("utf-8", { fatal: false }).decode(rawMessage);
- // Headers end at the first blank line. RFC 2822 mandates `CRLF CRLF`
- // but tolerate `LF LF` for callers that normalize line endings.
- let headerSection = text;
- const crlfBoundary = text.indexOf("\r\n\r\n");
- const lfBoundary = text.indexOf("\n\n");
- if (crlfBoundary >= 0 && (lfBoundary < 0 || crlfBoundary < lfBoundary)) {
- headerSection = text.slice(0, crlfBoundary);
- } else if (lfBoundary >= 0) {
- headerSection = text.slice(0, lfBoundary);
- }
+ const raw = new TextDecoder("utf-8", { fatal: false }).decode(rawMessage);
+ // Locating the blank line before checking the breaks is what keeps a `CRLF
+ // CRLF` the sender left in the body from becoming the separator: the section
+ // would then be read from that later offset, absorbing every field before it.
+ const boundary = BLANK_LINE.exec(raw);
+ const throughBoundary =
+ boundary === null ? raw.length : boundary.index + boundary[0].length;
+ if (BARE_LINE_BREAK.test(raw.slice(0, throughBoundary))) return null;
+ const headerSection = boundary === null ? raw : raw.slice(0, boundary.index);
// Unfold continuation lines (a line starting with WSP belongs to
// the prior header field).
- const lines = headerSection.split(/\r?\n/);
+ const lines = headerSection.split("\r\n");
const unfolded: string[] = [];
for (const line of lines) {
if (line.length > 0 && (line[0] === " " || line[0] === "\t")) {
@@ -76,7 +83,12 @@ export function parseMessageIdHeader(rawMessage: Uint8Array): string | null {
if (colon < 0) continue;
const name = line.slice(0, colon).trim().toLowerCase();
if (name !== "message-id") continue;
- return line.slice(colon + 1).trim();
+ const value = line.slice(colon + 1).trim();
+ // A blank header names no id (RFC 2822 §3.6.4 admits no empty `msg-id`).
+ // Returning `""` would give every such message the same id, and the dedup
+ // key built on it would discard the second as a redelivery of the first.
+ if (value.length === 0) return null;
+ return value;
}
return null;
}
diff --git a/vendor/intx-types/src/repo.ts b/vendor/intx-types/src/repo.ts
new file mode 100644
index 000000000..cffd8e4b3
--- /dev/null
+++ b/vendor/intx-types/src/repo.ts
@@ -0,0 +1,41 @@
+import { type } from "arktype";
+
+/**
+ * Tag identifying a kind of repository in the hub's kind-keyed RepoStore.
+ * Lives in `@intx/types` because the wire-level pack frames reference it;
+ * the substrate package re-exports it for handler authors.
+ */
+export const RepoKind = type.enumerated(
+ "agent-state",
+ "skill",
+ "package-registry",
+ "workflow",
+ "workflow-run",
+);
+export type RepoKind = typeof RepoKind.infer;
+
+/**
+ * Operations a principal may invoke against a repo in the RepoStore.
+ * Lives in `@intx/types` so storage layers (e.g. `@intx/db`) can validate
+ * persisted action vocabularies without depending on the substrate
+ * package. The substrate re-exports it for handler authors.
+ */
+export const RepoAction = type.enumerated(
+ "init",
+ "writeTree",
+ "receivePack",
+ "createPack",
+ "resolveRef",
+);
+export type RepoAction = typeof RepoAction.infer;
+
+/**
+ * Hub-side identity of a repository in the RepoStore. Pack frames carry
+ * this alongside `agentAddress` so the hub can map a pack back to the
+ * originating repo independently of which sidecar/agent it is destined for.
+ */
+export const RepoId = type({
+ kind: RepoKind,
+ id: "string",
+});
+export type RepoId = typeof RepoId.infer;
diff --git a/vendor/intx-types/src/runtime-core.ts b/vendor/intx-types/src/runtime-core.ts
new file mode 100644
index 000000000..6806b4234
--- /dev/null
+++ b/vendor/intx-types/src/runtime-core.ts
@@ -0,0 +1,2405 @@
+// Runtime definitions for the Interchange agent harness.
+//
+// Wire-facing data types (AbortReason, InferenceSource, ToolDefinition,
+// HarnessConfig) are arktype validators so they can be composed into
+// WebSocket frame validators and used for runtime validation at parse
+// boundaries. Behavioral interfaces (ContextStore, MessageTransport,
+// ToolRunner, etc.) remain plain TypeScript.
+
+import { type } from "arktype";
+import type { AuditRecord, ErrorRecord } from "./audit";
+import { WireGrantRule } from "./grant-wire";
+import type { SignalKind } from "./signals";
+
+// ---------------------------------------------------------------------------
+// Cryptographic Identity (ARCHITECTURE.md § Cryptographic Identity,
+// IMPLEMENTATION.md § Cryptographic Identity: Key Formats)
+// ---------------------------------------------------------------------------
+
+/**
+ * An Ed25519 key pair as raw bytes. The private key is 32 bytes; the public
+ * key is the corresponding 32-byte compressed point.
+ *
+ * Key material is represented as Uint8Array throughout so it stays
+ * runtime-agnostic (Bun, Node, browser) and never accidentally leaks through
+ * JSON serialization.
+ */
+export type KeyPair = {
+ privateKey: Uint8Array;
+ publicKey: Uint8Array;
+};
+
+/**
+ * A key-bound cryptographic provider. Each instance is constructed with a
+ * specific agent's Ed25519 key pair and holds the private key internally.
+ *
+ * `sign` uses the instance's own private key — no key parameter is accepted.
+ * `verify` accepts a public key parameter so the holder can verify messages
+ * from arbitrary senders without constructing a new provider instance.
+ *
+ * The in-memory transport stores one CryptoProvider per registered agent and
+ * calls `crypto.sign(content)` during `send()` without passing keys around.
+ *
+ * Key formats (IMPLEMENTATION.md):
+ * - Ed25519 in SSH format — control plane interactions
+ * - Ed25519 in PGP format — message-level signatures over SMTP/IMAP
+ * - Ed25519 in X.509 format — TLS mutual auth certificates
+ *
+ * `getPublicKey` returns the raw public key bytes so callers can publish
+ * them to the control plane or embed them in discovery metadata.
+ */
+export interface CryptoProvider {
+ /**
+ * Sign `content` with the instance's private key. Returns the Ed25519
+ * detached signature as raw bytes.
+ */
+ sign(content: Uint8Array): Promise;
+
+ /**
+ * Sign `payload` with the instance's private key using the SSH signature
+ * envelope (sshsig). Returns an ASCII-armored SSH SIGNATURE block suitable
+ * for the `gpgsig` header of a git commit or any other site that consumes
+ * `git verify-commit`-compatible signatures. The framing differs from
+ * `sign`'s raw output; callers that need either format should pick the
+ * matching method rather than reframing the result themselves.
+ */
+ signSSH(payload: string): Promise;
+
+ /**
+ * Verify that `signature` over `content` was produced by `publicKey`.
+ * Returns true if the signature is valid; false otherwise.
+ */
+ verify(
+ content: Uint8Array,
+ signature: Uint8Array,
+ publicKey: Uint8Array,
+ ): Promise;
+
+ /** The public key for this instance, as raw bytes. */
+ getPublicKey(): Uint8Array;
+}
+
+/**
+ * Generate a fresh Ed25519 key pair. The returned pair is used to construct
+ * a CryptoProvider instance.
+ */
+export type GenerateKeyPair = () => Promise;
+
+// ---------------------------------------------------------------------------
+// Message Transport (MESSAGE.md § Transport Interface)
+// ---------------------------------------------------------------------------
+
+/**
+ * Opaque reference to a message in a specific mailbox. Carries the IMAP UID
+ * and the mailbox name. Passed to fetch, flag, and move operations without
+ * requiring re-search.
+ */
+export type MessageRef = {
+ uid: number;
+ mailbox: string;
+};
+
+/**
+ * Interchange payload types as defined in MESSAGE.md § Payload Types.
+ * The type field in structured messages matches the Interchange-Type header.
+ *
+ * Exposed as both an arktype validator (for runtime validation at parse
+ * boundaries and tool-argument schemas) and a derived TypeScript union.
+ */
+export const InterchangeType = type.enumerated(
+ "conversation.message",
+ "conversation.join",
+ "conversation.leave",
+ "offering.request",
+ "offering.response",
+ "offering.error",
+ "offering.discover",
+ "offering.catalog",
+ "payment.required",
+ "payment.receipt",
+ "payment.verified",
+ "approval.request",
+ "approval.granted",
+ "approval.denied",
+ "system.health",
+ "system.register",
+ "system.deregister",
+ "system.credential.refresh",
+);
+export type InterchangeType = typeof InterchangeType.infer;
+
+const CONVERSATION_TYPES: ReadonlySet = new Set([
+ "conversation.message",
+ "conversation.join",
+ "conversation.leave",
+]);
+
+/**
+ * Membership is enumerated rather than derived from the `conversation.` name
+ * prefix, so a member added to InterchangeType is not a conversation type until
+ * it is listed in CONVERSATION_TYPES.
+ */
+export function isConversationType(value: InterchangeType): boolean {
+ return CONVERSATION_TYPES.has(value);
+}
+
+/** Attachment for an outbound message. Content is raw bytes. */
+export type MessageAttachment = {
+ name: string;
+ contentType: string;
+ data: Uint8Array;
+ /**
+ * IMAP BODY section of this part, stamped by `extractAttachments` from
+ * the parsed sibling numbering. Absent on outbound attachments.
+ */
+ part?: string;
+};
+
+/**
+ * A message the harness submits for delivery via SMTP. The transport
+ * assembles the PGP/MIME multipart structure, signs it with the agent's
+ * CryptoProvider, and submits it.
+ *
+ * Conversation types (conversation.*) carry `content` as text/plain.
+ * Structured types carry `payload` as application/vnd.interchange+json.
+ * Providing both is an error.
+ *
+ * (MESSAGE.md § Transport Interface › Outbound)
+ */
+export type OutboundMessage = {
+ to: string | string[];
+ cc?: string | string[];
+ subject?: string;
+
+ type: InterchangeType;
+
+ /** Plain text body — used when type is a conversation.* type. */
+ content?: string;
+
+ /** Structured JSON body — used when type is a non-conversation type. */
+ payload?: Record;
+
+ /** Human-readable summary for structured messages (the text/plain part). */
+ summary?: string;
+
+ attachments?: MessageAttachment[];
+
+ /** Message-ID of the message being replied to. */
+ inReplyTo?: string;
+
+ /**
+ * The RFC 5322 References chain for a threaded reply: the parent's own
+ * References plus the parent's Message-ID, in order. When present the
+ * transport ships it verbatim (after appending `inReplyTo` if it is not
+ * already the tail) rather than deriving a single-element `[inReplyTo]`
+ * chain, so a reply carries the full conversational ancestry. Absent for a
+ * non-reply or a reply whose parent could not be located.
+ */
+ references?: string[];
+
+ /** Correlation ID linking this message to a pending async request. */
+ correlationId?: string;
+
+ /** Reactor session ID from the Interchange-Session-ID header. */
+ sessionId?: string;
+
+ /** Tenant ID for the Interchange-Tenant-ID header. */
+ tenantId?: string;
+};
+
+/**
+ * Receipt returned by `send()`. Contains the assigned Message-ID and
+ * delivery status.
+ *
+ * (MESSAGE.md § Transport Interface › Outbound)
+ */
+export type SendReceipt = {
+ messageId: string;
+ status: "delivered" | "queued";
+};
+
+/**
+ * Parsed headers from an inbound message. Field names follow RFC 5322 and
+ * the Interchange-specific header conventions from MESSAGE.md § Headers.
+ *
+ * `from` is optional because a message can arrive carrying no originator at
+ * all, and there is no string that honestly stands for one.
+ */
+export type MessageHeaders = {
+ from?: string;
+ to: string[];
+ cc?: string[];
+ date?: string;
+ messageId?: string;
+ inReplyTo?: string;
+ references?: string[];
+ subject?: string;
+ listId?: string;
+
+ interchangeType?: InterchangeType;
+ interchangeCorrelationId?: string;
+ interchangeTenantId?: string;
+ interchangeAgentId?: string;
+ interchangeSessionId?: string;
+ interchangeOfferingId?: string;
+ interchangeSchemaVersion?: string;
+
+ traceparent?: string;
+ tracestate?: string;
+};
+
+/**
+ * Signature verification status of an inbound message.
+ *
+ * - `valid` — signature verified against the sender's public key
+ * - `invalid` — signature check failed (tampering or wrong key)
+ * - `unknown` — public key not available for verification
+ * - `missing` — message was not signed
+ *
+ * (MESSAGE.md § Transport Interface › fetchFull)
+ */
+export const SignatureStatus = type.enumerated(
+ "valid",
+ "invalid",
+ "unknown",
+ "missing",
+);
+export type SignatureStatus = typeof SignatureStatus.infer;
+
+/**
+ * A parsed MIME part. `content` is the DECODED bytes in memory (the
+ * transfer-encoding has already been undone). `filename` and `disposition` are
+ * surfaced from the part's `Content-Disposition` / `Content-Type` so a consumer
+ * can distinguish an inline part from a named attachment without re-parsing
+ * headers.
+ */
+export type MessagePart = {
+ contentType: string;
+ content: Uint8Array;
+ filename?: string;
+ disposition?: "inline" | "attachment";
+ /**
+ * The Content-Transfer-Encoding the part declared. Not set for a decoded
+ * mail part -- `content` is already decoded, so the wire encoding is spent
+ * transport metadata.
+ */
+ encoding?: string;
+};
+
+/**
+ * A single part of a persisted `Mail`. The bytes live in the durable store;
+ * this descriptor carries the part's metadata plus an opaque, relocation-stable
+ * `ref` a `MailPartReader` resolves to the part's bytes. Small UTF-8 text parts
+ * also carry their decoded `text` inline so a selector can read them without
+ * resolving.
+ */
+export type MailPart = {
+ contentType: string;
+ filename?: string;
+ disposition?: "inline" | "attachment";
+ ref: string;
+ text?: string;
+};
+
+/**
+ * The single, environment-agnostic interface for reading a persisted mail
+ * part's bytes. Modeled on `BlobReader`: a consumer -- a workflow step, an
+ * agent tool, the agent's content-block projection -- resolves a
+ * `MailPart.ref` to its bytes without knowing or caring where the bytes live
+ * (a committed file on the sidecar, a blob in a browser runtime). The `ref`
+ * is opaque; the reader owns its scheme. Threaded to consumers through the
+ * runtime so a browser runtime can supply its own implementation.
+ */
+export interface MailPartReader {
+ /** Resolve a `MailPart.ref` to the part's decoded bytes. Throws if the ref
+ * is unrecognized or its bytes are missing. */
+ read(ref: string): Promise;
+}
+
+/**
+ * A fully decoded mail message: every header (both the typed, ergonomic subset
+ * and a raw catch-all with nothing dropped) plus the flat list of decoded leaf
+ * parts. This is the lossless representation a deployed workflow receives as
+ * its trigger input; a workflow programs against it directly (select headers,
+ * walk parts, route on content type), and an agent step projects the parts into
+ * model content blocks. It is JSON-safe: each part carries a `ref` (not raw
+ * bytes), so binary content never enters the event log; the runtime resolves a
+ * `ref` into a loadable `MessagePart` on demand.
+ */
+export type Mail = {
+ headers: MessageHeaders;
+ /** Every header, lowercased name to its ordered values; nothing dropped. */
+ rawHeaders: Record;
+ parts: MailPart[];
+};
+
+const MailShape = type({
+ // Require the recipient list a consumer dereferences unconditionally; other
+ // header fields stay optional and are carried losslessly in `rawHeaders`.
+ // `from` is optional here deliberately: a mail with no usable originator is
+ // still mail, and requiring it makes `isMail` reject one.
+ headers: {
+ "from?": "string",
+ to: "string[]",
+ },
+ rawHeaders: {
+ "[string]": "string[]",
+ },
+ parts: type({
+ contentType: "string",
+ ref: "string",
+ "filename?": "string",
+ "disposition?": "'inline' | 'attachment'",
+ "text?": "string",
+ })
+ .onUndeclaredKey("reject")
+ .array(),
+}).onUndeclaredKey("reject");
+
+/**
+ * Narrow an opaque value (a workflow step input) to a `Mail`. Used at the
+ * `agent.send` boundary to decide whether the input is a mail-derived message
+ * whose parts must be projected into content blocks, or an arbitrary value
+ * delivered as synthesized text. The strict undeclared-key rejection keeps an
+ * arbitrary step value that merely carries a `parts` field from matching.
+ */
+export function isMail(value: unknown): value is Mail {
+ return !(MailShape(value) instanceof type.errors);
+}
+
+/**
+ * MIME tree metadata returned by `fetchStructure()`. Describes content types,
+ * sizes, and dispositions without transferring content.
+ *
+ * (MESSAGE.md § Partial Fetch)
+ */
+export type BodyStructure = {
+ contentType: string;
+ size?: number;
+ disposition?: string;
+ parts?: BodyStructure[];
+};
+
+/**
+ * A fully parsed inbound message including structured payload, headers,
+ * attachments, and signature verification status.
+ *
+ * (MESSAGE.md § Transport Interface › fetchFull)
+ */
+export type InboundMessage = {
+ ref: MessageRef;
+ headers: MessageHeaders;
+ flags: string[];
+
+ /** Plain text body for conversation.* types. */
+ content?: string;
+
+ /** Parsed JSON payload for structured types. */
+ payload?: {
+ type: InterchangeType;
+ version: string;
+ body: Record;
+ };
+
+ attachments?: MessageAttachment[];
+ signatureStatus: SignatureStatus;
+};
+
+/**
+ * IMAP mailbox descriptor.
+ *
+ * (MESSAGE.md § Inbox Management)
+ */
+export type Mailbox = {
+ name: string;
+ role?: string;
+ delimiter?: string;
+};
+
+/**
+ * Current status of an IMAP mailbox, including QRESYNC identifiers.
+ *
+ * (MESSAGE.md § Inbox Management)
+ */
+export type MailboxStatus = {
+ total: number;
+ unseen: number;
+ recent: number;
+ uidNext: number;
+ uidValidity: number;
+ highestModSeq: number;
+};
+
+/**
+ * Structured IMAP search query. Maps the IMAP SEARCH grammar to a typed
+ * object. Supports recursive boolean composition via `and`, `or`, `not`.
+ *
+ * (MESSAGE.md § Search)
+ */
+export type SearchQuery = {
+ from?: string;
+ to?: string;
+ cc?: string;
+ bcc?: string;
+ header?: { field: string; contains: string };
+ before?: Date;
+ after?: Date;
+ on?: Date;
+ sentBefore?: Date;
+ sentAfter?: Date;
+ sentOn?: Date;
+ hasFlags?: string[];
+ missingFlags?: string[];
+ body?: string;
+ text?: string;
+ largerThan?: number;
+ smallerThan?: number;
+ and?: SearchQuery[];
+ or?: SearchQuery[];
+ not?: SearchQuery;
+};
+
+/**
+ * A thread node returned by `thread()`. Carries a message reference and
+ * child threads representing replies. Implements the RFC 5256 REFERENCES
+ * threading algorithm.
+ *
+ * (MESSAGE.md § Thread Retrieval)
+ */
+export type Thread = {
+ ref: MessageRef;
+ children: Thread[];
+};
+
+/**
+ * QRESYNC state the harness provides when reconnecting to the transport.
+ *
+ * (MESSAGE.md § Synchronization)
+ */
+export type SyncState = {
+ uidValidity: number;
+ uidNext: number;
+ highestModSeq: number;
+ knownUids?: number[];
+};
+
+/**
+ * Result of a QRESYNC-style sync operation.
+ *
+ * (MESSAGE.md § Synchronization)
+ */
+export type SyncResult = {
+ vanished: number[];
+ changed: { uid: number; flags: string[] }[];
+ newMessages: MessageRef[];
+ fullResyncRequired: boolean;
+};
+
+/**
+ * Distribution list metadata returned by `createList()`.
+ *
+ * (MESSAGE.md § Message Topologies)
+ */
+export type ListInfo = {
+ address: string;
+ name: string;
+ memberCount: number;
+ createdAt: string;
+};
+
+/**
+ * Event emitted by the mailbox watcher callback. Corresponds to IMAP IDLE
+ * notifications.
+ *
+ * (MESSAGE.md § Real-Time Notification)
+ */
+export type MailboxEvent =
+ | { type: "exists"; uid: number; headers: MessageHeaders }
+ | { type: "flagsChanged"; uid: number; flags: string[] }
+ | { type: "expunged"; uid: number };
+
+/** Unsubscribe function returned by `watch()`. */
+export type Unsubscribe = () => void;
+
+/**
+ * The message transport interface. Abstracts SMTP and IMAP behind a
+ * TypeScript API. Implementations range from real SMTP/IMAP servers to
+ * in-process stubs that route messages through memory.
+ *
+ * All long-running operations accept an AbortSignal for cooperative
+ * cancellation.
+ *
+ * (MESSAGE.md § Transport Interface)
+ */
+export interface MessageTransport {
+ // --- Outbound ---
+
+ /** Compose, sign, and deliver a message via SMTP. */
+ send(message: OutboundMessage, signal?: AbortSignal): Promise;
+
+ /** Append a raw message to a mailbox (IMAP APPEND). */
+ append(
+ mailbox: string,
+ message: InboundMessage,
+ flags?: string[],
+ signal?: AbortSignal,
+ ): Promise;
+
+ // --- Mailbox management ---
+
+ listMailboxes(signal?: AbortSignal): Promise;
+ createMailbox(name: string, signal?: AbortSignal): Promise;
+ deleteMailbox(name: string, signal?: AbortSignal): Promise;
+ getMailboxStatus(name: string, signal?: AbortSignal): Promise;
+
+ // --- Message search and retrieval ---
+
+ search(
+ mailbox: string,
+ query: SearchQuery,
+ signal?: AbortSignal,
+ ): Promise;
+
+ thread(
+ mailbox: string,
+ algorithm: "references" | "orderedsubject",
+ query?: SearchQuery,
+ signal?: AbortSignal,
+ ): Promise;
+
+ fetchHeaders(ref: MessageRef, signal?: AbortSignal): Promise;
+ fetchStructure(ref: MessageRef, signal?: AbortSignal): Promise;
+ fetchPart(
+ ref: MessageRef,
+ partPath: string,
+ signal?: AbortSignal,
+ ): Promise;
+ fetchFull(ref: MessageRef, signal?: AbortSignal): Promise;
+
+ // --- Flag management ---
+
+ setFlags(
+ ref: MessageRef,
+ flags: string[],
+ signal?: AbortSignal,
+ ): Promise;
+
+ clearFlags(
+ ref: MessageRef,
+ flags: string[],
+ signal?: AbortSignal,
+ ): Promise;
+
+ // --- Message organization ---
+
+ move(ref: MessageRef, toMailbox: string, signal?: AbortSignal): Promise;
+
+ copy(ref: MessageRef, toMailbox: string, signal?: AbortSignal): Promise;
+
+ /**
+ * Permanently remove every `\Deleted` message from the mailbox. Returns the
+ * uids that were expunged, so a caller can report how many messages it
+ * consumed and which ones.
+ */
+ expunge(
+ mailbox: string,
+ signal?: AbortSignal,
+ ): Promise<{ expungedUids: number[] }>;
+
+ // --- Real-time notification ---
+
+ /**
+ * Monitor a mailbox for new messages and flag changes (IMAP IDLE).
+ * The promise resolves once the watch is accepted. Callers await it
+ * before relying on the callback, and a refusal rejects with the
+ * transport's condition. The unsubscribe function stays synchronous.
+ */
+ watch(
+ mailbox: string,
+ callback: (event: MailboxEvent) => void,
+ ): Promise;
+
+ // --- Synchronization ---
+
+ /** Efficient reconnection using QRESYNC semantics. */
+ sync(
+ mailbox: string,
+ knownState: SyncState,
+ signal?: AbortSignal,
+ ): Promise;
+
+ // --- Distribution lists ---
+
+ createList(
+ address: string,
+ name: string,
+ signal?: AbortSignal,
+ ): Promise;
+
+ listMembers(address: string, signal?: AbortSignal): Promise;
+
+ subscribe(
+ listAddress: string,
+ subscriberAddress: string,
+ signal?: AbortSignal,
+ ): Promise;
+
+ unsubscribe(
+ listAddress: string,
+ subscriberAddress: string,
+ signal?: AbortSignal,
+ ): Promise;
+}
+
+/**
+ * The condition a `MessageTransport` operation failed under. A transport that
+ * grows a condition this union does not carry takes its name from RFC 5530 § 3
+ * rather than coining one.
+ *
+ * https://www.rfc-editor.org/rfc/rfc5530.html
+ */
+export const MessageTransportCondition = type(
+ "'NONEXISTENT' | 'CANNOT' | 'SERVERBUG'",
+);
+export type MessageTransportCondition = typeof MessageTransportCondition.infer;
+
+export class MessageTransportError extends Error {
+ readonly condition: MessageTransportCondition;
+
+ constructor(condition: MessageTransportCondition, message: string) {
+ super(message);
+ this.name = "MessageTransportError";
+ this.condition = condition;
+ }
+}
+
+/**
+ * `instanceof` is not usable for this: a tool package is published as a bundle
+ * whose workspace imports are inlined (`bin/build-builtins.ts`), so a consumer
+ * loaded from a bundle holds its own copy of the class above and answers false
+ * against an error the host's copy constructed.
+ */
+export function isMessageTransportError(
+ value: unknown,
+): value is Error & { readonly condition: MessageTransportCondition } {
+ if (!(value instanceof Error)) return false;
+ if (!("condition" in value)) return false;
+ return MessageTransportCondition.allows(value.condition);
+}
+
+// ---------------------------------------------------------------------------
+// Tool Execution (ARCHITECTURE.md § Tools, INFERENCE.md § Tool Execution)
+// ---------------------------------------------------------------------------
+
+/**
+ * A tool call as requested by the model. Carries the provider-assigned call
+ * ID, the tool name, and the parsed arguments.
+ *
+ * (INFERENCE.md § Message Format › Content Types)
+ */
+export const ToolCall = type({
+ id: "string",
+ name: "string",
+ arguments: "Record",
+});
+export type ToolCall = typeof ToolCall.infer;
+
+/**
+ * Approver-facing snapshot of the tool call awaiting approval. Built at the
+ * authz `ask` branch from the tool's definition and the live call, then
+ * threaded unchanged from the reactor's pending operation through every
+ * suspend hop to the hub co-write that records it on the approval row.
+ *
+ * `name`, `description`, and `inputSchema` mirror the {@link ToolDefinition};
+ * `arguments` is the live call's arguments. Carried as a sibling of the pending
+ * operation's `suspendedCall`, never folded into {@link ToolCall}, so the
+ * re-dispatch artifact and the approval snapshot stay separate concerns.
+ */
+export const ApprovalSnapshot = type({
+ name: "string",
+ description: "string",
+ inputSchema: "Record",
+ arguments: "Record",
+});
+export type ApprovalSnapshot = typeof ApprovalSnapshot.infer;
+
+/**
+ * The kind of a control-plane park: a step suspended awaiting an external
+ * event. `"approval"` and `"input"` park on a reserved
+ * `signalName(correlationId)` channel; `"signal-relay"` parks on an
+ * author-chosen name.
+ *
+ * - `"approval"` -- the step parked on a tool/authz gate and REQUIRES an
+ * {@link ApprovalSnapshot}; the runtime notifies the host (`env.onPark`) so
+ * the sidecar co-writes the approval/correlation rows the hub registers.
+ * - `"input"` -- the step parked awaiting its next input (e.g. a long-lived
+ * agent run awaiting the next mail so it can take another turn). It carries
+ * NO snapshot and does NOT notify the host: the run's owner delivers the
+ * input on the same channel and the step re-arms. It is a runtime-local
+ * concept -- deliberately NOT a {@link SignalKind}, so it never touches the
+ * approval-routing machinery (IPC register frames, the hub co-write, the
+ * approval columns).
+ * - `"signal-relay"` -- an onTrigger section container parked on an
+ * author-named signal so a body child's `awaitSignal` on that name is
+ * serviced through the deployment run: the external signal is delivered to
+ * the parent run and the runtime relays it down into the live body child.
+ * The channel name is the author's free-form signal name, NOT a reserved
+ * `signalName(correlationId)`, so recovery must branch on this kind BEFORE
+ * assuming the awaited name is a reserved control-plane channel. Carries no
+ * snapshot and is not hub-registered.
+ *
+ * The kinds are distinguished by an EXPLICIT discriminant everywhere the kind
+ * flows -- never inferred from the presence or absence of a snapshot, which
+ * would silently reclassify a malformed snapshot-less approval as another
+ * park kind rather than failing loud.
+ */
+export const ControlParkKind = type.enumerated(
+ "approval",
+ "input",
+ "signal-relay",
+);
+export type ControlParkKind = typeof ControlParkKind.infer;
+
+/**
+ * Maximum serialized size, in UTF-8 bytes, of an {@link ApprovalSnapshot} that
+ * crosses a trust boundary. A tool `inputSchema` is normally single-digit KB;
+ * a snapshot approaching this bound is malformed or hostile and is rejected at
+ * the parse boundary rather than co-written onto an approval row.
+ */
+export const APPROVAL_SNAPSHOT_MAX_BYTES = 131072;
+
+/**
+ * {@link ApprovalSnapshot} bounded to {@link APPROVAL_SNAPSHOT_MAX_BYTES}.
+ * Parse the snapshot through this validator where it crosses a trust boundary
+ * (the `park.notify` IPC frame, the `parked-correlations.response` IPC frame,
+ * and the sidecar→hub register frame); internal hops use the unbounded
+ * {@link ApprovalSnapshot}. `.narrow` bounds the runtime check only — its
+ * inferred type is identical to {@link ApprovalSnapshot} — so the cap holds only
+ * where a frame is actually parsed, not merely typed.
+ */
+export const BoundedApprovalSnapshot = ApprovalSnapshot.narrow(
+ (snapshot, ctx) => {
+ const bytes = Buffer.byteLength(JSON.stringify(snapshot), "utf8");
+ return (
+ bytes <= APPROVAL_SNAPSHOT_MAX_BYTES ||
+ ctx.mustBe(`at most ${APPROVAL_SNAPSHOT_MAX_BYTES} bytes when serialized`)
+ );
+ },
+);
+export type BoundedApprovalSnapshot = typeof BoundedApprovalSnapshot.infer;
+
+/**
+ * Result of a tool execution. `content` is text or structured data the model
+ * sees as the tool result. `detail` is additional data that the harness may
+ * use (e.g., for validation or audit) but that is not shown to the model.
+ *
+ * When `isError` is true the model sees the result as an error. When
+ * `pendingMarker` is present the tool is async — the reactor registers the
+ * correlation ID and waits for a matching inbound message.
+ *
+ * (INFERENCE.md § Tool Execution Semantics)
+ */
+export const ToolResult = type({
+ callId: "string",
+ content: "string | Record",
+ "detail?": "unknown",
+ "isError?": "boolean",
+ "pendingMarker?": {
+ status: "'pending'",
+ correlationId: "string",
+ },
+});
+export type ToolResult = typeof ToolResult.infer;
+
+/**
+ * The tool runner interface. The harness implements this; the reactor calls
+ * it when the director requests tool execution.
+ *
+ * Parallel execution is modeled by calling `run` concurrently for each call
+ * in a batch — the interface is per-call, not per-batch.
+ *
+ * (ARCHITECTURE.md § Agent Harness › Tools)
+ */
+export interface ToolRunner {
+ /**
+ * Execute a single tool call. Resolves with the result. Must not throw —
+ * errors are returned as `ToolResult` with `isError: true`.
+ */
+ run(call: ToolCall, signal: AbortSignal): Promise;
+}
+
+// ---------------------------------------------------------------------------
+// Inference Event Building Blocks (INFERENCE.md § Event Protocol)
+// ---------------------------------------------------------------------------
+
+/**
+ * Partial assistant message accumulated during streaming. Carries all
+ * content blocks seen so far so late-joining subscribers receive current
+ * state without replaying deltas.
+ *
+ * `text` and `thinking` are cumulative across every emitted delta of
+ * that kind in the current turn — intentionally flat, even when the
+ * harness's per-index block tracking has split the stream into
+ * multiple ThinkingBlocks or TextBlocks. Consumers that need per-block
+ * structure walk the finalized inference.done turn's content[]; this
+ * snapshot is the live "what bytes has the assistant streamed so
+ * far" view.
+ *
+ * (INFERENCE.md § Event Protocol › Partial State)
+ */
+export const PartialMessage = type({
+ text: "string",
+ "thinking?": "string",
+ "toolCalls?": type({
+ id: "string",
+ name: "string",
+ partialArguments: "string",
+ }).array(),
+});
+export type PartialMessage = typeof PartialMessage.infer;
+
+/**
+ * Token usage for a single inference call. Cache read/write counts are
+ * provider-specific and may be zero when the provider does not report them.
+ *
+ * (INFERENCE.md § Token Accounting)
+ */
+export const TokenUsage = type({
+ input: "number",
+ output: "number",
+ cacheRead: "number",
+ cacheWrite: "number",
+ thinking: "number",
+});
+export type TokenUsage = typeof TokenUsage.infer;
+
+/**
+ * Slim source descriptor stamped onto `inference.usage` / `inference.done`
+ * events and onto `ReactorState.lastCycleSource`.
+ *
+ * Carries enough identity for state-aware policies (cost gating, budget
+ * caps, governance triggers, audit) to attribute usage to a specific
+ * inference source without re-reading the live, mutable `InferenceSource`
+ * the harness owns.
+ *
+ * Deliberately a strict subset of `InferenceSource` — `apiKey` and
+ * `baseURL` are intentionally excluded. Credentials and endpoints must
+ * not leak to director-side policy code or to external event consumers.
+ * Any code path that needs the full source obtains it through the
+ * harness's source registry, not through this descriptor.
+ *
+ * `sourceId` aliases `InferenceSource.id` to disambiguate from message
+ * ids, turn ids, and session ids in director-side code where `id` alone
+ * would be ambiguous.
+ */
+export const LastCycleSource = type({
+ sourceId: "string",
+ provider: "string",
+ model: "string",
+});
+export type LastCycleSource = typeof LastCycleSource.infer;
+
+// ---------------------------------------------------------------------------
+// Internal Turn Format (INFERENCE.md § Message Format)
+// ---------------------------------------------------------------------------
+
+/**
+ * A single content block within a conversation turn. Provider-agnostic.
+ *
+ * (INFERENCE.md § Message Format › Content Types)
+ */
+const TextBlock = type({
+ type: "'text'",
+ text: "string",
+ // Opaque provider signature authenticating this block, echoed back
+ // verbatim on follow-up turns. Gemini attaches a `thoughtSignature` to
+ // output parts (including plain text); absent for providers that do not
+ // sign this block kind.
+ "signature?": "string",
+});
+
+/**
+ * How a media payload is carried by a content block. One of three
+ * variants: inline as a base64-encoded string, by reference to an
+ * opaque provider-native handle (e.g. a Gemini fileUri, an Anthropic
+ * file_id), or by public URL the provider fetches itself. The wire
+ * shape each provider expects is built by the provider adapter;
+ * MediaSource is the internal, provider-agnostic representation.
+ *
+ * (INFERENCE.md § Generalized Multimodal Taxonomy)
+ */
+const MediaSourceBase64 = type({
+ kind: "'base64'",
+ mimeType: "string",
+ data: "string",
+});
+
+const MediaSourceFileReference = type({
+ kind: "'file-reference'",
+ mimeType: "string",
+ reference: "string",
+});
+
+const MediaSourceUrl = type({
+ kind: "'url'",
+ mimeType: "string",
+ url: "string",
+});
+
+export const MediaSource = type.or(
+ MediaSourceBase64,
+ MediaSourceFileReference,
+ MediaSourceUrl,
+);
+export type MediaSource = typeof MediaSource.infer;
+
+// Exported because `inference.image_output` events reference it by
+// name, following the same pattern as `CitationBlock`,
+// `CodeExecutionRequestBlock`, and `RedactedThinkingBlock`.
+export const ImageBlock = type({
+ type: "'image'",
+ source: MediaSource,
+ // Opaque provider signature authenticating this block, echoed back
+ // verbatim on follow-up turns. Gemini rides a `thoughtSignature` on the
+ // inlineData part; absent otherwise.
+ "signature?": "string",
+});
+export type ImageBlock = typeof ImageBlock.infer;
+
+const AudioBlock = type({
+ type: "'audio'",
+ source: MediaSource,
+});
+
+const VideoBlock = type({
+ type: "'video'",
+ source: MediaSource,
+});
+
+const DocumentBlock = type({
+ type: "'document'",
+ source: MediaSource,
+ "title?": "string",
+ "context?": "string",
+});
+
+const ThinkingBlock = type({
+ type: "'thinking'",
+ thinking: "string",
+ "signature?": "string",
+});
+
+/**
+ * A thinking block whose content the provider has filtered. The
+ * opaque `data` blob must echo back verbatim on every follow-up turn
+ * — Anthropic 400s the request if it changes or goes missing. Treat
+ * the bytes as opaque: do not log them and do not render them to
+ * users.
+ *
+ * Exported because `inference.thinking.redacted` events reference it
+ * by name.
+ */
+export const RedactedThinkingBlock = type({
+ type: "'redacted_thinking'",
+ data: "string",
+});
+export type RedactedThinkingBlock = typeof RedactedThinkingBlock.infer;
+
+/**
+ * A model-emitted refusal. Produced when a provider's strict-mode
+ * structured-outputs path declines to satisfy the requested schema —
+ * OpenAI's `delta.refusal` / `message.refusal` field is the canonical
+ * wire shape. The `reason` is the accumulated human-readable text the
+ * model emitted in lieu of conformant output.
+ *
+ * Refusal is semantically distinct from `inference.error`: the HTTP
+ * call succeeded and the model produced a coherent response, but that
+ * response is "I will not satisfy this schema" rather than schema-
+ * conformant content. Callers that distinguish policy declines from
+ * transport/protocol failures should branch on the block type rather
+ * than treat the assistant turn as an error.
+ *
+ * Exported because `inference.refusal.delta` events reference it by
+ * name and adapters construct RefusalBlocks in the finalized
+ * AssistantTurn from accumulated delta fragments.
+ */
+export const RefusalBlock = type({
+ type: "'refusal'",
+ // Refusals must carry text — a zero-length reason corrupts the
+ // "human-readable text the model emitted in lieu of conformant
+ // output" contract and would round-trip indistinguishably from a
+ // refusal block whose payload was lost. The arktype constraint is
+ // belt-and-braces alongside the adapter's wire-boundary filter on
+ // empty `delta.refusal` chunks: synthetic fixtures or future
+ // adapters without that filter still cannot construct a vacuous
+ // refusal.
+ reason: "string > 0",
+});
+export type RefusalBlock = typeof RefusalBlock.infer;
+const ToolCallBlock = type({
+ type: "'tool_call'",
+ id: "string",
+ name: "string",
+ arguments: "Record",
+ // Opaque provider signature authenticating this block, echoed back
+ // verbatim on follow-up turns. Gemini rides a `thoughtSignature` on the
+ // functionCall part; absent otherwise.
+ "signature?": "string",
+});
+/**
+ * Location of a citation's cited span within its source document.
+ * The unit of `start` and `end` varies by `kind`:
+ * - "page": 1-indexed page numbers (Anthropic `page_location`).
+ * - "char": UTF-16 character offsets, matching JS string semantics
+ * (Anthropic `char_location`; Gemini `groundingSupports[].segment`).
+ * - "content-block": index into a structured source's content blocks
+ * (Anthropic `content_block_location`).
+ */
+const CitationLocation = type({
+ kind: "'page' | 'char' | 'content-block'",
+ start: "number",
+ end: "number",
+});
+
+const CitationSource = type({
+ "title?": "string",
+ // Self-contained dereferenceable URL — populated by providers whose
+ // citations carry URLs directly (Gemini `groundingChunks[].web.uri`).
+ "uri?": "string",
+ // Back-pointer into the request's `documents` array, populated by
+ // providers that cite uploaded documents by position (Anthropic
+ // `document_index`).
+ "documentRef?": type({ index: "number" }),
+});
+
+/**
+ * A citation that supports a span of assistant text. Consumers
+ * receiving a CitationBlock without a paired source-block index MUST
+ * attribute it by adjacency to the nearest preceding TextBlock in the
+ * same turn.
+ *
+ * Citations are deliberately excluded from ToolResultBlock.content
+ * — they annotate model output, not tool output.
+ *
+ * Exported because `inference.citation` events reference it by name,
+ * following the same pattern as `AssistantTurn`, `ToolCall`, and
+ * `ToolResult`. See the `inference.citation` event docstring for how
+ * a paired source-block index is carried on the wire and consumed by
+ * the harness.
+ */
+export const CitationBlock = type({
+ type: "'citation'",
+ // The exact substring of the preceding TextBlock this citation
+ // supports. Both providers emit it; required for inspection and
+ // for fallback offset reconstruction.
+ citedText: "string",
+ source: CitationSource,
+ "location?": CitationLocation,
+ // UTF-16 character offsets into the preceding TextBlock's text.
+ // Providers that emit offsets natively populate these directly;
+ // adapters that derive offsets from a cited substring populate
+ // them only when the substring appears unambiguously in the
+ // preceding text. Omitted when the offset cannot be determined.
+ "textOffset?": type({ start: "number", end: "number" }),
+});
+export type CitationBlock = typeof CitationBlock.infer;
+
+/**
+ * A structured safety signal on model output or request filtering.
+ *
+ * The name `SafetyRatingBlock` follows the issue vocabulary; the
+ * payload is derived from the first real Gemini capture that engaged
+ * the structured classifier (2026-07-28). That wire shape is
+ * prompt-level only:
+ *
+ * `promptFeedback: { blockReason: "PROHIBITED_CONTENT" }`
+ *
+ * with no candidates and no per-category `safetyRatings` arrays. So
+ * this block carries `blockReason` and does **not** invent category /
+ * probability / blocked fields. When a future capture surfaces
+ * candidate-level ratings, extend the type from those bytes rather
+ * than from the API reference.
+ *
+ * Deliberately excluded from ToolResultBlock.content — safety
+ * signals annotate model/request filtering, not tool output.
+ *
+ * Exported because `inference.safety_rating` events reference it by
+ * name.
+ */
+export const SafetyRatingBlock = type({
+ type: "'safety_rating'",
+ // Provider-native block reason string (observed: "PROHIBITED_CONTENT").
+ // Open string so a new reason token does not force a type bump.
+ blockReason: "string > 0",
+});
+export type SafetyRatingBlock = typeof SafetyRatingBlock.infer;
+
+/**
+ * The model's request to execute code via a server-side execution tool.
+ * Paired with a CodeExecutionResultBlock carrying the same `id` as the
+ * result's `requestId`. Streaming order within a single execution is
+ * `inference.code_execution.start` → zero or more
+ * `inference.code_execution.delta` → `inference.code_execution.result`,
+ * uninterrupted by other events that share the same `requestId`; events
+ * with different `requestId`s or for other block kinds at distinct
+ * `index`es may interleave.
+ *
+ * Exported because `inference.code_execution.start` references it by
+ * name.
+ */
+export const CodeExecutionRequestBlock = type({
+ type: "'code_execution_request'",
+ // Identifier for the execution request. Populated from the
+ // provider's call id where one exists (Anthropic
+ // `srvtoolu_...`); synthesized by the adapter for providers that
+ // don't emit one (Gemini), using a deterministic per-response
+ // position-based scheme so replays match.
+ id: "string",
+ // Source code the model is asking to execute.
+ code: "string",
+ // Language hint. Absent when the provider does not emit one;
+ // adapters MUST NOT default this — callers narrow on its
+ // presence rather than fall through to a guessed language.
+ "language?": "string",
+ // Opaque provider signature authenticating this block, echoed back
+ // verbatim on follow-up turns. Gemini rides a `thoughtSignature` on the
+ // executableCode part; absent otherwise.
+ "signature?": "string",
+});
+export type CodeExecutionRequestBlock = typeof CodeExecutionRequestBlock.infer;
+
+/**
+ * The result of executing a CodeExecutionRequestBlock. The `requestId`
+ * back-points to the request block's `id`. Status is normalized across
+ * providers; raw provider signals (return code, native outcome string,
+ * abort reason) are preserved on optional fields for callers that need
+ * them.
+ *
+ * File outputs from code execution (e.g. generated plots that
+ * Anthropic returns in `code_execution_tool_result.content`) are NOT
+ * modeled by this block today. The block carries no field for them;
+ * surfacing file outputs is a separate concern.
+ *
+ * Exported because `inference.code_execution.result` references it by
+ * name.
+ */
+export const CodeExecutionResultBlock = type({
+ type: "'code_execution_result'",
+ // Back-pointer to the originating CodeExecutionRequestBlock.id.
+ requestId: "string",
+ // Normalized outcome. Translated from provider-specific signals:
+ // - Anthropic: derived from `return_code` (0 → "ok", non-zero →
+ // "error") and `abort_reason` (non-null → "aborted" or
+ // "timeout" per the reason).
+ // - Gemini: derived from the `outcome` enum
+ // (OUTCOME_OK → "ok", OUTCOME_FAILED → "error",
+ // OUTCOME_DEADLINE_EXCEEDED → "timeout", etc.).
+ status: "'ok' | 'error' | 'aborted' | 'timeout'",
+ // Standard output. Providers that don't split stdout from stderr
+ // (Gemini) map their combined `output` here and leave `stderr` empty.
+ "stdout?": "string",
+ // Standard error. Empty for providers that don't split.
+ "stderr?": "string",
+ // Provider-native numeric return code when available
+ // (Anthropic `return_code`). Absent for providers whose outcome
+ // is enum-only (Gemini).
+ "returnCode?": "number",
+ // Provider-native outcome string preserved verbatim for callers
+ // that need the raw signal (Gemini `OUTCOME_OK` /
+ // `OUTCOME_FAILED` / `OUTCOME_DEADLINE_EXCEEDED` / ...). Absent
+ // when the provider does not emit one (Anthropic).
+ "providerOutcome?": "string",
+ // Human-readable reason populated when status is "aborted"
+ // (Anthropic `abort_reason`). Absent otherwise.
+ "abortReason?": "string",
+});
+export type CodeExecutionResultBlock = typeof CodeExecutionResultBlock.infer;
+
+const ToolResultBlock = type({
+ type: "'tool_result'",
+ callId: "string",
+ // Deliberately narrow: tool results carry user-facing media, not
+ // CitationBlocks (citations annotate the model's text output), not
+ // SafetyRatingBlocks (safety signals annotate model/request
+ // filtering), and not CodeExecution blocks (server-side code
+ // execution is a distinct lifecycle from the user-tool round-trip).
+ content: type
+ .or(TextBlock, ImageBlock, AudioBlock, VideoBlock, DocumentBlock)
+ .array(),
+ "detail?": "unknown",
+ "isError?": "boolean",
+});
+
+export const ContentBlock = type.or(
+ TextBlock,
+ ThinkingBlock,
+ RedactedThinkingBlock,
+ RefusalBlock,
+ ImageBlock,
+ AudioBlock,
+ VideoBlock,
+ DocumentBlock,
+ CitationBlock,
+ SafetyRatingBlock,
+ CodeExecutionRequestBlock,
+ CodeExecutionResultBlock,
+ ToolCallBlock,
+ ToolResultBlock,
+);
+export type ContentBlock = typeof ContentBlock.infer;
+
+/**
+ * A turn in the internal conversation history. The `model` field records
+ * which provider model produced this turn (present only on assistant
+ * turns). Used by cross-provider transformation to strip or preserve
+ * thinking blocks.
+ *
+ * (INFERENCE.md § Message Format)
+ */
+export type ConversationTurn = {
+ role: "user" | "assistant" | "system";
+ content: ContentBlock[];
+ model?: string;
+ timestamp: number;
+};
+
+/**
+ * A completed assistant turn returned in `inference.done`. Narrower type
+ * than ConversationTurn to make the inference boundary explicit.
+ */
+export const AssistantTurn = type({
+ role: "'assistant'",
+ content: ContentBlock.array(),
+ model: "string",
+ timestamp: "number",
+});
+export type AssistantTurn = typeof AssistantTurn.infer;
+
+// ---------------------------------------------------------------------------
+// Error Classification (INFERENCE.md § Error Classification)
+// ---------------------------------------------------------------------------
+
+/**
+ * Classified inference error. The category determines the reactor's default
+ * response; the director can override per its policy.
+ *
+ * (INFERENCE.md § Error Classification)
+ */
+export const InferenceError = type({
+ category: type.enumerated(
+ "retryable",
+ "context_overflow",
+ "credential_failure",
+ "quota_exhausted",
+ "fatal",
+ "aborted",
+ "timeout",
+ "protocol_mismatch",
+ ),
+ message: "string",
+ "statusCode?": "number",
+ "retryAfterMs?": "number",
+ "raw?": "unknown",
+});
+export type InferenceError = typeof InferenceError.infer;
+
+// ---------------------------------------------------------------------------
+// Agent Reactor (INFERENCE.md § Agent Reactor)
+// ---------------------------------------------------------------------------
+
+/**
+ * Gate types that can block the reactor.
+ *
+ * (INFERENCE.md § Gates)
+ */
+export const GateType = type.enumerated(
+ "approval",
+ "payment",
+ "credential",
+ "budget",
+ "child_completion",
+ "message_response",
+);
+export type GateType = typeof GateType.infer;
+
+/**
+ * Fork mode. `independent` creates a divergent reactor with its own context.
+ * `child` creates a reactor that reports results back to the parent.
+ *
+ * (INFERENCE.md § Forking)
+ */
+export const ForkMode = type.enumerated("independent", "child");
+export type ForkMode = typeof ForkMode.infer;
+
+/**
+ * A pending async operation registered in the reactor's async state.
+ * Correlates an outbound message (or payment/approval request) to the
+ * expected inbound response.
+ *
+ * (INFERENCE.md § Correlation)
+ */
+export type PendingOperation = {
+ correlationId: string;
+ kind: SignalKind;
+ registeredAt: number;
+ gateId: string;
+ /**
+ * Absolute deadline (epoch ms) for the gate that parks this operation.
+ * Persisted so that rehydration after a restart re-arms the gate with the
+ * remaining time against the original deadline rather than restarting the
+ * countdown. Absent for operations parked with no deadline.
+ */
+ timeoutAt?: number;
+ /**
+ * The tool call that was suspended when this operation parked. Captured for
+ * `kind: "approval"` operations minted from the ask flow so the approved
+ * call can be re-run on resume. Absent for operations parked by the
+ * director path (async-tool pending markers), which carry no tool call.
+ */
+ suspendedCall?: ToolCall;
+ /**
+ * Approver-facing snapshot of `suspendedCall`, built at the authz `ask`
+ * branch from the tool definition and the live arguments. A sibling of
+ * `suspendedCall`, not a widening of it: `suspendedCall` is the re-dispatch
+ * artifact, this is what the approver decides on. Present only for ask-rail
+ * operations that carry a `suspendedCall`; absent for async-tool pending
+ * markers. Threaded through the suspend hops to the hub co-write.
+ */
+ approvalSnapshot?: ApprovalSnapshot;
+};
+
+/**
+ * Complete reactor state visible to the director decision function.
+ *
+ * `tokenUsage` is the cumulative usage across the session.
+ *
+ * `lastCycleUsage` and `lastCycleSource` describe the most recent
+ * *successful* inference call. They move together: both null before the
+ * first completion; every `inference.done` sets both atomically.
+ * `inference.error` does not clear either — the pair always reflects the
+ * last cycle that produced a well-defined turn and usage. The director's
+ * `afterInferenceDone` hook fires only on `inference.done`, so policy
+ * code never observes a torn or stale-vs-fresh window.
+ *
+ * The per-cycle values support compaction triggers that key off recent
+ * input cost rather than session totals, and state-aware policies (cost
+ * gating, budget caps, governance triggers) that need to attribute
+ * usage to the source that produced it.
+ *
+ * (INFERENCE.md § Agent Reactor › Director Decision Function)
+ */
+export type ReactorState = {
+ turns: ConversationTurn[];
+ activeForks: { forkId: string; mode: ForkMode }[];
+ pendingOperations: PendingOperation[];
+ activeGates: { gateId: string; type: GateType; timeoutAt: number }[];
+ tokenUsage: TokenUsage;
+ lastCycleUsage: TokenUsage | null;
+ lastCycleSource: LastCycleSource | null;
+ sessionId: string;
+};
+
+/**
+ * Actions the director can direct the reactor to take.
+ *
+ * (INFERENCE.md § Agent Reactor › Actions)
+ */
+export type ReactorAction =
+ | {
+ type: "infer";
+ options?: InferenceOptions;
+ }
+ | {
+ type: "execute_tools";
+ calls: ToolCall[];
+ parallel?: boolean;
+ addToHistory?: boolean;
+ }
+ | {
+ type: "suspend";
+ gate: {
+ type: GateType;
+ gateId: string;
+ timeoutMs: number;
+ correlationId?: string;
+ };
+ }
+ | {
+ type: "fork";
+ mode: ForkMode;
+ forkId: string;
+ }
+ | {
+ type: "emit";
+ eventType: `custom.${string}`;
+ data: Record;
+ }
+ | {
+ type: "reply";
+ content: string;
+ }
+ | { type: "checkpoint"; message: string }
+ | { type: "compact"; compactor: string; reason: string }
+ | { type: "wait" }
+ | { type: "done" };
+
+/**
+ * The capabilities object passed to the director. Mirrors the `ReactorAction`
+ * union — provides a type-safe way for the director to construct actions.
+ *
+ * (INFERENCE.md § Agent Reactor › Director Decision Function)
+ */
+export type ReactorCapabilities = {
+ infer(options?: InferenceOptions): ReactorAction;
+ executeTools(
+ calls: ToolCall[],
+ parallel?: boolean,
+ addToHistory?: boolean,
+ ): ReactorAction;
+ suspend(gate: {
+ type: GateType;
+ gateId: string;
+ timeoutMs: number;
+ correlationId?: string;
+ }): ReactorAction;
+ fork(mode: ForkMode, forkId: string): ReactorAction;
+ emit(
+ eventType: `custom.${string}`,
+ data: Record,
+ ): ReactorAction;
+ reply(content: string): ReactorAction;
+ checkpoint(message?: string): ReactorAction;
+ compact(compactor: string, reason: string): ReactorAction;
+ wait(): ReactorAction;
+ done(): ReactorAction;
+};
+
+/**
+ * The inbound events delivered to the director decision function.
+ *
+ * `resume.execute_tools` is raised by the reactor when an approval resolves
+ * and a parked tool call must be re-run on resume. It carries the calls the
+ * reactor is about to dispatch so the director can seed its outstanding
+ * tool-result count before those calls' `tool.done` events arrive — the
+ * reactor drives the execution, the director counts the results. Without this
+ * seed the count would sit at zero and the first `tool.done` would drive an
+ * accidental re-inference off a negative count.
+ *
+ * `resume.tool_result` is raised by the reactor when a parked approval ends
+ * without running its tool — a rejected decision or a gate timeout. It carries
+ * a synthetic error tool result that answers the parked call so history stays
+ * well-formed; the director appends it and re-infers exactly once. No tool
+ * runs, so it seeds no outstanding-result count.
+ *
+ * (INFERENCE.md § Agent Reactor › Reactor Structure)
+ */
+export type ReactorInboundEvent =
+ | { type: "message.received"; message: InboundMessage }
+ | {
+ type: "inference.done";
+ turn: AssistantTurn;
+ usage: TokenUsage;
+ source: LastCycleSource;
+ }
+ | { type: "inference.error"; error: InferenceError; partial: PartialMessage }
+ | { type: "tool.done"; result: ToolResult }
+ | {
+ type: "reactor.gate.cleared";
+ gateId: string;
+ reason: "resolved" | "timeout" | "shutdown";
+ }
+ | { type: "resume.execute_tools"; calls: ToolCall[] }
+ | { type: "resume.tool_result"; result: ToolResult }
+ | { type: "abort"; reason: AbortReason };
+
+/**
+ * The core director is a single decision function: given an event and the
+ * current reactor state, return one or more actions.
+ *
+ * If the director throws, the reactor catches the exception, emits
+ * `reactor.error`, and initiates graceful shutdown.
+ *
+ * (INFERENCE.md § Reactor Director › Core Director)
+ */
+export interface ReactorDirector {
+ decide(
+ event: ReactorInboundEvent,
+ state: ReactorState,
+ capabilities: ReactorCapabilities,
+ ): Promise;
+}
+
+// ---------------------------------------------------------------------------
+// Director Extension Hooks (INFERENCE.md § Reactor Director › Extension Hooks)
+// ---------------------------------------------------------------------------
+
+/**
+ * Decision returned by a `BeforeToolExtension`.
+ *
+ * - `allow` — the tool proceeds.
+ * - `block` — the tool is answered with an error result carrying `reason`;
+ * the call is done.
+ * - `suspend` — the call is parked awaiting an external decision. The reactor
+ * registers `gate`, persists `pendingOp`, and does not answer the call: it
+ * is neither run nor error-completed. `gate.timeoutAt` is the absolute
+ * deadline (epoch ms) so the reactor can compute the remaining time; the
+ * `correlationId` on both `gate` and `pendingOp` ties an inbound resolution
+ * back to the suspension.
+ */
+export type BeforeToolDecision =
+ | { type: "allow" }
+ | { type: "block"; reason: string }
+ | {
+ type: "suspend";
+ gate: {
+ type: GateType;
+ gateId: string;
+ correlationId: string;
+ timeoutAt: number;
+ };
+ pendingOp: PendingOperation;
+ };
+
+/**
+ * Extension that runs before a tool call is executed. Returns a
+ * `BeforeToolDecision`: `allow` lets the call run, `block` answers it with an
+ * error result, `suspend` parks it awaiting an external decision.
+ *
+ * `grantOneShot` registers a within-cycle bypass token keyed on a
+ * `ToolCall.id`: the next `beforeTool` for that id skips a suspension it would
+ * otherwise raise, consuming the token as it does so. It is optional because
+ * only extensions that can suspend a call have anything to bypass; extensions
+ * that never suspend omit it.
+ */
+export interface BeforeToolExtension {
+ beforeTool(
+ call: ToolCall,
+ state: ReactorState,
+ signal: AbortSignal,
+ ): Promise;
+ grantOneShot?(id: string): void;
+}
+
+/**
+ * Extension that runs after a tool result is produced. Can modify the result
+ * (redaction, enrichment, audit logging). Extensions run in order.
+ */
+export interface AfterToolExtension {
+ afterTool(
+ result: ToolResult,
+ call: ToolCall,
+ state: ReactorState,
+ signal: AbortSignal,
+ ): Promise;
+}
+
+// ---------------------------------------------------------------------------
+// Context Strategies: Transforms and Compactors
+// (INFERENCE.md § Context Management, § Tool Result Lifecycle)
+// ---------------------------------------------------------------------------
+
+/**
+ * Durable description of a single strategy invocation. Written to the
+ * per-cycle manifest in the context store so that future operators can
+ * reconstruct exactly which strategy made which change, with what
+ * parameters, and why.
+ *
+ * - `strategy` is the implementation name (e.g. `"size-cap"`).
+ * - `version` is the implementation version. Changes to the strategy's
+ * behavior bump the version so old manifest entries remain unambiguous.
+ * - `parameters` records the configuration the strategy ran with.
+ * - `reason` is a short machine-readable cause label
+ * (e.g. `"exceeded-cap"`, `"overflow-recovery"`).
+ * - `decisions` records strategy-specific details about what was actually
+ * done (e.g. the keep count, the spill key, the original byte size).
+ */
+export const TransformRecord = type({
+ strategy: "string",
+ version: "string",
+ parameters: "Record",
+ reason: "string",
+ decisions: "Record",
+});
+export type TransformRecord = typeof TransformRecord.infer;
+
+/**
+ * Per-invocation context passed to every `ContextStrategy.apply` call.
+ * `state` is the reactor's snapshot at the moment the strategy runs;
+ * `trigger` is a short label describing why the strategy was invoked
+ * (e.g. `"tool-result-ingest"`, `"pre-inference"`, `"director-request"`).
+ */
+export interface StrategyContext {
+ readonly state: ReactorState;
+ readonly trigger: string;
+}
+
+/**
+ * Optional blob attachment emitted by a strategy. The reactor writes each
+ * blob to the context store's working tree via `ContextStore.writeBlob`
+ * (Phase 2) so the data is durable and migrates with the conversation.
+ */
+export type StrategyBlob = {
+ key: string;
+ bytes: Uint8Array;
+ contentType?: string;
+};
+
+/**
+ * Result returned by `ContextStrategy.apply`. Carries the transformed
+ * output, a `TransformRecord` describing what happened, and any blobs
+ * that should be persisted in the context store.
+ */
+export interface StrategyResult {
+ output: O;
+ record: TransformRecord;
+ blobs?: StrategyBlob[];
+}
+
+/**
+ * Generic base interface for content-mutating strategies. The role-specific
+ * aliases below specialize `I` and `O` for tool-result ingestion, pre-
+ * inference context shaping, and explicit compaction.
+ *
+ * Strategies are pure with respect to the context store: they describe what
+ * should change via their return value. The reactor decides where to write
+ * the result (history, prompt, manifest) and which blobs to persist.
+ */
+export interface ContextStrategy {
+ readonly name: string;
+ readonly version: string;
+ apply(input: I, ctx: StrategyContext): Promise>;
+}
+
+/**
+ * Runs on each tool result entering history. Output is appended to the
+ * conversation; any emitted blobs are written to the context store's
+ * `tool-output/` directory.
+ */
+export type ToolResultTransform = ContextStrategy<
+ { call: ToolCall; result: ToolResult },
+ ToolResult
+>;
+
+/**
+ * Runs in order before every inference call, producing the materialized
+ * prompt. Output is written to `prompt.jsonl` for that cycle; the durable
+ * history in `turns.jsonl` is left untouched.
+ *
+ * (INFERENCE.md § Async State Awareness › Pending Status Injection)
+ */
+export type ContextTransform = ContextStrategy<
+ ConversationTurn[],
+ ConversationTurn[]
+>;
+
+/**
+ * Named compaction strategy. Registered in a registry on the reactor and
+ * invoked explicitly via the director's `compact` action. Output overwrites
+ * `turns.jsonl`; a `TransformRecord` is appended to the manifest.
+ */
+export type Compactor = ContextStrategy;
+
+// ---------------------------------------------------------------------------
+// Blob Reader (INFERENCE.md § Tool Result Lifecycle)
+// ---------------------------------------------------------------------------
+
+/**
+ * Read-only capability for resolving `tool-output:///{callId}` URIs to the
+ * underlying blob bytes. A `ToolResultTransform` that spills oversized tool
+ * output writes a blob via `ContextStore.writeBlob` and returns a pointer of
+ * the form `tool-output:///{callId}`; the agent's read tool reaches the spill
+ * by calling `BlobReader.read(uri)`.
+ *
+ * The URI scheme is deliberately rigid:
+ *
+ * - Scheme: `tool-output`
+ * - Authority: empty (the `///` makes pathname carry the callId)
+ * - Path: `/{callId}` — preserves case so provider-assigned callIds with
+ * uppercase letters survive parsing
+ * - Query and fragment: rejected
+ *
+ * Any deviation (different scheme, missing or non-empty hostname, extra path
+ * segments, search string, or fragment) throws. Missing blobs throw.
+ * `BlobReader` never accepts a filesystem path; the agent has no direct view
+ * of the context store's working tree.
+ */
+export interface BlobReader {
+ /**
+ * Resolve `uri` to the underlying blob bytes. Throws if the URI is not a
+ * well-formed `tool-output:///{callId}` reference or if no blob exists for
+ * the extracted callId.
+ */
+ read(uri: string): Promise;
+}
+
+/** Source for blob bytes used by `createBlobReader`. */
+export interface BlobSource {
+ readBlob(key: string, signal?: AbortSignal): Promise;
+}
+
+/**
+ * Parse a `tool-output:///{callId}` URI and return the callId. Throws on any
+ * deviation from the documented shape: wrong scheme, non-empty authority,
+ * missing or extra path components, search string, or fragment.
+ *
+ * The two-slash form `tool-output://abc` is rejected because the URL parser
+ * lowercases the hostname, which silently corrupts provider-assigned callIds
+ * that contain uppercase letters. The three-slash form puts the callId in
+ * `pathname`, where case is preserved.
+ */
+export function parseToolOutputURI(uri: string): string {
+ let parsed: URL;
+ try {
+ parsed = new URL(uri);
+ } catch (cause) {
+ throw new Error(`invalid tool-output URI: ${uri}`, { cause });
+ }
+ if (parsed.protocol !== "tool-output:") {
+ throw new Error(
+ `invalid tool-output URI scheme: expected "tool-output:", got "${parsed.protocol}"`,
+ );
+ }
+ if (parsed.hostname !== "") {
+ throw new Error(
+ `invalid tool-output URI: authority must be empty (use the form tool-output:///{callId}), got "${parsed.hostname}"`,
+ );
+ }
+ if (parsed.search !== "") {
+ throw new Error(
+ `invalid tool-output URI: query string is not allowed, got "${parsed.search}"`,
+ );
+ }
+ if (parsed.hash !== "") {
+ throw new Error(
+ `invalid tool-output URI: fragment is not allowed, got "${parsed.hash}"`,
+ );
+ }
+ const path = parsed.pathname;
+ if (!path.startsWith("/")) {
+ throw new Error(`invalid tool-output URI: empty path: ${uri}`);
+ }
+ const callId = path.slice(1);
+ if (callId === "") {
+ throw new Error(`invalid tool-output URI: missing callId: ${uri}`);
+ }
+ if (callId.includes("/")) {
+ throw new Error(
+ `invalid tool-output URI: path must contain a single callId segment, got "${callId}"`,
+ );
+ }
+ return callId;
+}
+
+/**
+ * Construct a `BlobReader` that resolves `tool-output:///{callId}` URIs by
+ * delegating to `source.readBlob(callId)`. The most common source is a
+ * `ContextStore` (Phase 2 added `readBlob` to that interface), but any object
+ * implementing `BlobSource` works — this keeps tests trivial.
+ *
+ * URI parsing is performed in this layer; the source only ever sees the
+ * extracted callId. Missing blobs surface as whatever error the source
+ * raises (`ContextStore.readBlob` already throws for unknown keys).
+ */
+export function createBlobReader(source: BlobSource): BlobReader {
+ return {
+ async read(uri: string): Promise {
+ const callId = parseToolOutputURI(uri);
+ return source.readBlob(callId);
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Abort Reasons (INFERENCE.md § Abort Handling)
+// ---------------------------------------------------------------------------
+
+/**
+ * Reason codes for the `abort` reactor event. The reason determines the
+ * appropriate cleanup action.
+ *
+ * (INFERENCE.md § Abort Handling › Abort Reasons)
+ */
+export const AbortReason = type.enumerated(
+ "user_disconnect",
+ "wallet_exhaustion",
+ "admin_kill",
+ "session_timeout",
+ "credential_revocation",
+);
+export type AbortReason = typeof AbortReason.infer;
+
+// ---------------------------------------------------------------------------
+// Inference Source (INFERENCE.md § Providers)
+// ---------------------------------------------------------------------------
+
+/**
+ * Model-bound default knobs for an inference source. Per-call
+ * `InferenceOptions.X` overrides `defaults.X`; the merge happens once at
+ * the top of `runInference` before the adapter sees anything. New fields
+ * land here as separately-scoped issues.
+ */
+export const InferenceSourceDefaults = type({
+ "maxTokens?": "number",
+ // A bag of provider-native knobs the caller wants merged into the
+ // outbound request body (Anthropic's `metadata.user_id`,
+ // OpenAI's `user`, Gemini's `safetySettings`, etc.). Adapters that
+ // recognize keys translate; unrecognized keys are passed through or
+ // dropped per the adapter's documented behavior. The merge into
+ // per-call `InferenceOptions.providerOptions` is shallow — a per-
+ // call providerOptions object wholesale replaces the source-bound
+ // one, it does not deep-merge per key.
+ "providerOptions?": "Record",
+});
+export type InferenceSourceDefaults = typeof InferenceSourceDefaults.infer;
+
+/**
+ * A specific (provider, model) bundle the agent runtime can route to.
+ * Carries wire reachability, credentials, the model identity at the
+ * provider, and the model-bound default knobs.
+ *
+ * `id` is the catalog offering's primary key, set by the resolver from the
+ * matched offering. It is the routing key used by `AgentConfig.defaultSource`
+ * and `Agent.setSource`.
+ *
+ * Multi-model providers become multiple sources — `model` is part of the
+ * identity, not an optional override.
+ *
+ * `capabilities` is carried for the selection-policy layer (the model
+ * selector consumes it). The runtime ignores it; populating the field
+ * later is not a wire-format change.
+ *
+ * `quirks` is the opaque per-deployment bag of provider-specific adapter
+ * accommodations. The harness reads it once, at adapter instantiation, and
+ * passes it to `AdapterRegistry.resolve` as a sibling of the slim
+ * `LastCycleSource` — quirks are deliberately kept off `LastCycleSource`,
+ * which rides on every usage event. The field is present-and-populated or
+ * absent; it is never `null`. A source row with no quirks stores SQL `NULL`,
+ * and the catalog resolver translates that absence into an omitted key here,
+ * so downstream code sees `undefined`, never `null`.
+ *
+ * (INFERENCE.md § Providers)
+ */
+export const InferenceSource = type({
+ id: "string",
+ provider: "string",
+ baseURL: "string",
+ // Reference into the run's credential-material cell. The provider's secret
+ // (formerly an inline `apiKey`) is resolved from that cell by `credentialId`
+ // at call time, so the source config carries no secret and the child never
+ // holds the key inline. The same cell backs tool credentials.
+ credentialId: "string",
+ model: "string",
+ "defaults?": InferenceSourceDefaults,
+ "capabilities?": "string[]",
+ "quirks?": "Record",
+});
+export type InferenceSource = typeof InferenceSource.infer;
+
+/**
+ * Replace every field on `active` with the corresponding field from
+ * `next`, in place. Optional fields (`defaults`, `capabilities`,
+ * `quirks`) are `delete`d from `active` when absent on `next` so the
+ * swap is exact — no stale value from a previous rotation can survive.
+ *
+ * Used by both the agent's source registry and the harness's source
+ * hot-swap path to mutate the single shared `InferenceSource` object the
+ * reactor reads lazily at the start of each inference call. Putting the
+ * field list in one place means the next field added to
+ * `InferenceSource` only has to be remembered here.
+ */
+export function applyInferenceSourceFields(
+ active: InferenceSource,
+ next: InferenceSource,
+): void {
+ active.id = next.id;
+ active.provider = next.provider;
+ active.baseURL = next.baseURL;
+ active.credentialId = next.credentialId;
+ active.model = next.model;
+ if (next.defaults !== undefined) {
+ active.defaults = next.defaults;
+ } else {
+ delete active.defaults;
+ }
+ if (next.capabilities !== undefined) {
+ active.capabilities = next.capabilities;
+ } else {
+ delete active.capabilities;
+ }
+ if (next.quirks !== undefined) {
+ active.quirks = next.quirks;
+ } else {
+ delete active.quirks;
+ }
+
+ // Compile-time exhaustiveness check. `Required<>` forces optional
+ // keys to also be required in the guard — so a future optional field
+ // (e.g. `region?: string`) added to `InferenceSource` without being
+ // handled above is flagged by TypeScript, not silently dropped.
+ const _handled: { readonly [K in keyof Required]: true } = {
+ id: true,
+ provider: true,
+ baseURL: true,
+ credentialId: true,
+ model: true,
+ defaults: true,
+ capabilities: true,
+ quirks: true,
+ };
+ void _handled;
+}
+
+/**
+ * Outcome of a `RetryPolicy` consultation. Either abort the call
+ * (surface the most recent `inference.error` to the caller), or retry
+ * after `delayMs` milliseconds, measured against the harness Scheduler.
+ *
+ * (INFERENCE.md § Providers › Streaming Harness)
+ */
+export type RetryDecision =
+ // Locally patched — see vendor/intx-types/PATCHES.md#runtime-ts-auth-recovery-context
+ | { kind: "abort"; error?: InferenceError }
+ | { kind: "retry"; delayMs: number };
+
+/**
+ * Context supplied to a `RetryPolicy` each time an attempt produces an
+ * `inference.error`.
+ *
+ * (INFERENCE.md § Providers › Streaming Harness)
+ */
+export type RetrySituation = {
+ /** The classified error the most recent attempt produced. */
+ readonly error: InferenceError;
+ /**
+ * 1-indexed attempt counter. The first failure has `attempt: 1`;
+ * the second failure (after one retry) has `attempt: 2`; and so on.
+ */
+ readonly attempt: number;
+ /**
+ * Milliseconds since the *first* attempt of this call started,
+ * measured via the harness `Scheduler.now()`. The default Scheduler
+ * uses `performance.now()` (sub-millisecond resolution), so the
+ * value may be fractional; virtual-clock test schedulers report
+ * integer virtual time. Both are valid; policies that compare
+ * against integer thresholds should `Math.floor` if they need that.
+ */
+ readonly elapsedMs: number;
+ // Locally patched — see vendor/intx-types/PATCHES.md#runtime-ts-auth-recovery-context
+ /** Immutable shallow snapshot of the source selected when this call began. */
+ readonly source?: Readonly;
+ /** 1-indexed within credential failures only; zero for other categories. */
+ readonly credentialFailureOrdinal?: number;
+ /** Credential failures observed earlier in this call. */
+ readonly credentialFailureHistory?: readonly InferenceError[];
+};
+
+/**
+ * Per-call retry policy. The harness invokes the policy once per
+ * `inference.error` an attempt produces, in 1-indexed attempt order.
+ * Returning `{ kind: "abort" }` ends the call by surfacing the most
+ * recent error to the caller; returning `{ kind: "retry", delayMs }`
+ * causes the harness to discard the failed attempt's events, sleep
+ * `delayMs` milliseconds against the Scheduler, and re-issue the
+ * underlying HTTP request with the identical body. The policy may be
+ * async; the harness awaits the returned `Promise