From 6f0296b2c43ef3d03a64c2c26407fa36f253c73f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 7 Sep 2026 13:39:51 -0700 Subject: [PATCH 1/2] Vendor mailbox, harness, and mime, and ledger kill dates for all vendor trees Step 1 of v0.4.0 needs primitives that upstream Interchange has but npm does not. npm verification first showed @intx/mailbox has never been published, @intx/harness 0.3.0 lacks the reply-drain connector drain, and @intx/mime 0.3.0 lacks buildMessageHeaders (which vendored mailbox re-exports), so those three are vendored verbatim at the same upstream commit as the existing trees. The hub-sessions substrate store, mail-memory transport, and the workflow-host step-invoker onEvent seam are all covered by published 0.3.0 packages, so they are pinned as dependencies instead of vendored. The never-published substrate-mailbox-store adapter from workflow-host is vendored as a partial-package source directory with no package wiring until Step 1 consumes it. Every vendored path now has a provenance row with an owner and a proposed kill date. --- bun.lock | 188 ++- docs/VENDORING.md | 76 +- package.json | 14 +- vendor/intx-agent/package.json | 2 +- vendor/intx-harness/LICENSE | 176 +++ vendor/intx-harness/README.md | 71 + vendor/intx-harness/package.json | 24 + .../intx-harness/src/connector-router.test.ts | 718 +++++++++ vendor/intx-harness/src/connector-router.ts | 304 ++++ .../src/credential-capability.test.ts | 311 ++++ .../intx-harness/src/credential-capability.ts | 178 +++ .../src/credential-providers.test.ts | 294 ++++ .../intx-harness/src/credential-providers.ts | 179 +++ vendor/intx-harness/src/harness.test.ts | 873 +++++++++++ vendor/intx-harness/src/harness.ts | 462 ++++++ vendor/intx-harness/src/index.ts | 50 + vendor/intx-harness/src/reply-drain.test.ts | 468 ++++++ vendor/intx-harness/src/reply-drain.ts | 315 ++++ .../src/runtime-capabilities.test.ts | 19 + .../intx-harness/src/runtime-capabilities.ts | 22 + vendor/intx-harness/tsconfig.json | 4 + vendor/intx-mailbox/LICENSE | 176 +++ vendor/intx-mailbox/README.md | 30 + vendor/intx-mailbox/package.json | 18 + vendor/intx-mailbox/src/fetch.test.ts | 218 +++ vendor/intx-mailbox/src/fetch.ts | 250 +++ vendor/intx-mailbox/src/headers.ts | 5 + vendor/intx-mailbox/src/index.ts | 11 + vendor/intx-mailbox/src/mailbox.ts | 192 +++ vendor/intx-mailbox/src/search.ts | 208 +++ vendor/intx-mailbox/src/thread.ts | 275 ++++ vendor/intx-mailbox/tsconfig.json | 4 + vendor/intx-mime/LICENSE | 176 +++ vendor/intx-mime/README.md | 32 + vendor/intx-mime/package.json | 17 + vendor/intx-mime/src/index.test.ts | 1147 ++++++++++++++ vendor/intx-mime/src/index.ts | 44 + vendor/intx-mime/src/mail-builder.test.ts | 740 +++++++++ vendor/intx-mime/src/mail-builder.ts | 516 +++++++ vendor/intx-mime/src/mail-decode.test.ts | 218 +++ vendor/intx-mime/src/mime.ts | 1334 +++++++++++++++++ vendor/intx-mime/src/pgp-sign.ts | 29 + vendor/intx-mime/tsconfig.json | 4 + .../adapters/substrate-mailbox-store.test.ts | 586 ++++++++ .../adapters/substrate-mailbox-store.ts | 563 +++++++ 45 files changed, 11526 insertions(+), 15 deletions(-) create mode 100644 vendor/intx-harness/LICENSE create mode 100644 vendor/intx-harness/README.md create mode 100644 vendor/intx-harness/package.json create mode 100644 vendor/intx-harness/src/connector-router.test.ts create mode 100644 vendor/intx-harness/src/connector-router.ts create mode 100644 vendor/intx-harness/src/credential-capability.test.ts create mode 100644 vendor/intx-harness/src/credential-capability.ts create mode 100644 vendor/intx-harness/src/credential-providers.test.ts create mode 100644 vendor/intx-harness/src/credential-providers.ts create mode 100644 vendor/intx-harness/src/harness.test.ts create mode 100644 vendor/intx-harness/src/harness.ts create mode 100644 vendor/intx-harness/src/index.ts create mode 100644 vendor/intx-harness/src/reply-drain.test.ts create mode 100644 vendor/intx-harness/src/reply-drain.ts create mode 100644 vendor/intx-harness/src/runtime-capabilities.test.ts create mode 100644 vendor/intx-harness/src/runtime-capabilities.ts create mode 100644 vendor/intx-harness/tsconfig.json create mode 100644 vendor/intx-mailbox/LICENSE create mode 100644 vendor/intx-mailbox/README.md create mode 100644 vendor/intx-mailbox/package.json create mode 100644 vendor/intx-mailbox/src/fetch.test.ts create mode 100644 vendor/intx-mailbox/src/fetch.ts create mode 100644 vendor/intx-mailbox/src/headers.ts create mode 100644 vendor/intx-mailbox/src/index.ts create mode 100644 vendor/intx-mailbox/src/mailbox.ts create mode 100644 vendor/intx-mailbox/src/search.ts create mode 100644 vendor/intx-mailbox/src/thread.ts create mode 100644 vendor/intx-mailbox/tsconfig.json create mode 100644 vendor/intx-mime/LICENSE create mode 100644 vendor/intx-mime/README.md create mode 100644 vendor/intx-mime/package.json create mode 100644 vendor/intx-mime/src/index.test.ts create mode 100644 vendor/intx-mime/src/index.ts create mode 100644 vendor/intx-mime/src/mail-builder.test.ts create mode 100644 vendor/intx-mime/src/mail-builder.ts create mode 100644 vendor/intx-mime/src/mail-decode.test.ts create mode 100644 vendor/intx-mime/src/mime.ts create mode 100644 vendor/intx-mime/src/pgp-sign.ts create mode 100644 vendor/intx-mime/tsconfig.json create mode 100644 vendor/intx-workflow-host/adapters/substrate-mailbox-store.test.ts create mode 100644 vendor/intx-workflow-host/adapters/substrate-mailbox-store.ts diff --git a/bun.lock b/bun.lock index 7bd399079..7a0157fb1 100644 --- a/bun.lock +++ b/bun.lock @@ -7,12 +7,18 @@ "dependencies": { "@intx/agent": "workspace:*", "@intx/authz": "workspace:*", + "@intx/harness": "workspace:*", + "@intx/hub-sessions": "0.3.0", "@intx/inference": "workspace:*", "@intx/log": "workspace:*", + "@intx/mail-memory": "0.3.0", + "@intx/mailbox": "workspace:*", + "@intx/mime": "workspace:*", "@intx/storage-isogit": "workspace:*", "@intx/tools-lsp": "0.3.0", "@intx/tools-posix": "workspace:*", "@intx/types": "workspace:*", + "@intx/workflow-host": "0.3.0", "@modelcontextprotocol/sdk": "^1.29.0", "@opentui/core": "0.5.10", "arktype": "catalog:", @@ -54,7 +60,7 @@ "dependencies": { "@intx/inference": "workspace:*", "@intx/log": "workspace:*", - "@intx/mime": "0.3.0", + "@intx/mime": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:", }, @@ -69,6 +75,22 @@ "@intx/types": "workspace:*", }, }, + "vendor/intx-harness": { + "name": "@intx/harness", + "version": "0.2.2", + "dependencies": { + "@intx/agent": "workspace:*", + "@intx/authz": "workspace:*", + "@intx/log": "workspace:*", + "@intx/types": "workspace:*", + }, + "devDependencies": { + "@intx/inference-testing": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/storage-isogit": "workspace:*", + "arktype": "catalog:", + }, + }, "vendor/intx-inference": { "name": "@intx/inference", "version": "0.2.2", @@ -95,6 +117,25 @@ "hono", ], }, + "vendor/intx-mailbox": { + "name": "@intx/mailbox", + "version": "0.2.2", + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + }, + }, + "vendor/intx-mime": { + "name": "@intx/mime", + "version": "0.2.2", + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/types": "workspace:*", + "arktype": "catalog:", + }, + }, "vendor/intx-storage-isogit": { "name": "@intx/storage-isogit", "version": "0.2.2", @@ -137,8 +178,11 @@ "overrides": { "@intx/agent": "workspace:*", "@intx/authz": "workspace:*", + "@intx/harness": "workspace:*", "@intx/inference": "workspace:*", "@intx/log": "workspace:*", + "@intx/mailbox": "workspace:*", + "@intx/mime": "workspace:*", "@intx/storage-isogit": "workspace:*", "@intx/tools-posix": "workspace:*", "@intx/types": "workspace:*", @@ -181,6 +225,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -199,6 +245,14 @@ "@intx/crypto": ["@intx/crypto@0.3.0", "", { "dependencies": { "@intx/types": "0.3.0" } }, "sha512-NsRzvkFGb0Pcsm9uLFFSXNl6Cbu+ii6VkYAw81qs9IbZzbAFfsZSUVQaX6EZuPNyJ8KK4KmBtqmioq1mhXsJww=="], + "@intx/db": ["@intx/db@0.3.0", "", { "dependencies": { "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/log": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "postgres": "^3.4.8" } }, "sha512-MXNeUVJTftQuSHj5zSUBQsUcFN3wUBs8t6yT6lqjbaNxpT1C7kGx8qXb95WuadZln+krlLhyoUwGMF7EmYvlnw=="], + + "@intx/harness": ["@intx/harness@workspace:vendor/intx-harness"], + + "@intx/hub-common": ["@intx/hub-common@0.3.0", "", { "dependencies": { "@intx/types": "0.3.0" } }, "sha512-dA8pTmBTu1JHGd3rGJrMsRWrai4GBBNnPxpZFFtXwQPzgL290lx2R+9BpRo2apdKbmgd+KOVshLvOdcUhAqghw=="], + + "@intx/hub-sessions": ["@intx/hub-sessions@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "0.3.0", "@intx/hub-common": "0.3.0", "@intx/log": "0.3.0", "@intx/mime": "0.3.0", "@intx/pack-transport": "0.3.0", "@intx/storage-isogit": "0.3.0", "@intx/tool-packaging": "0.3.0", "@intx/types": "0.3.0", "@intx/workflow": "0.3.0", "@intx/workflow-deploy": "0.3.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "isomorphic-git": "^1.27.2", "tar": "^7.5.1" } }, "sha512-L9B/vuQakvbj4CYrEhbNwu0z/S9/2jBXmaJdxaC1zfakbOiHya4HFW/YAZKAgOi7z0cpxFftsK/RN3Yyrd/+Wg=="], + "@intx/inference": ["@intx/inference@workspace:vendor/intx-inference"], "@intx/inference-discovery": ["@intx/inference-discovery@0.3.0", "", { "dependencies": { "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-JdlwqTuF5z53Hfy36ANZVrbCjhFoqHgJiVNyV/LSNpTA/sPGFxutuPB1MRG5gTtDpLE0tJV775NiRXGqp+F3zA=="], @@ -207,16 +261,32 @@ "@intx/log": ["@intx/log@workspace:vendor/intx-log"], - "@intx/mime": ["@intx/mime@0.3.0", "", { "dependencies": { "@intx/crypto": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-jpKZZpfWRQJ6fI8BEpHnr83N+1ZHrnOMY978B/6bgcjFn3qtm1f3KiR0yaQ8eF3r1VF327r3H2qiCCx0OFN9Ow=="], + "@intx/mail-memory": ["@intx/mail-memory@0.3.0", "", { "dependencies": { "@intx/crypto": "0.3.0", "@intx/log": "0.3.0", "@intx/mime": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-A0bEHi0tbGxZ9Wpvt9YwrbGkekirBJvKtzgG+dm3/tLkLfWuaEFHTJ8IuSnR3CRyjeLcNw8ItuznXRFs4WfgcA=="], + + "@intx/mailbox": ["@intx/mailbox@workspace:vendor/intx-mailbox"], + + "@intx/mime": ["@intx/mime@workspace:vendor/intx-mime"], + + "@intx/pack-transport": ["@intx/pack-transport@0.3.0", "", { "dependencies": { "@intx/types": "0.3.0" } }, "sha512-EQ1wM16323aIFhw3Ak6HwB8YqzKSAfRmJUwjIfqyXk2t6TGv4grws6dIs7Yau1dc372s/SO+GhvFaKgvI6XTWA=="], "@intx/storage-isogit": ["@intx/storage-isogit@workspace:vendor/intx-storage-isogit"], + "@intx/tool-packaging": ["@intx/tool-packaging@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/log": "0.3.0", "@intx/storage-isogit": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29", "isomorphic-git": "^1.27.2", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^19.0.0", "semver": "^7.7.2", "ssri": "^12.0.0", "tar": "^7.5.1" } }, "sha512-Lr/xdEjXTindJRm+eITvfeqpW/yl71thYjr3HXl41GJ9wpuOZPCjm9vBoRVKhUSwmIUKBYbm2VplXCCXaS7f4g=="], + "@intx/tools-lsp": ["@intx/tools-lsp@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/log": "0.3.0", "@intx/tools-posix": "0.3.0", "@intx/types": "0.3.0", "vscode-jsonrpc": "^9.0.1", "vscode-languageserver-types": "^3.17.5", "which": "^4.0.0" } }, "sha512-Ywnm9NNfCLlkzXTL1UoHyVvee4eaQ9rqKkkHM8zmf4l8L+ZqEPIW4IG5nERJG9F9XxOaf4gboqbFgqMe+AF5Dg=="], "@intx/tools-posix": ["@intx/tools-posix@workspace:vendor/intx-tools-posix"], "@intx/types": ["@intx/types@workspace:vendor/intx-types"], + "@intx/workflow": ["@intx/workflow@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/inference": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-vaIqTohNNdCVhvZ4btWbIbpjp3140e5939tQP7t+rSkucTvnn6US+bXho1qUJyNQ577mf3YXHGgBCW8ppsnscA=="], + + "@intx/workflow-deploy": ["@intx/workflow-deploy@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/tool-packaging": "0.3.0", "@intx/types": "0.3.0", "@intx/workflow": "0.3.0" } }, "sha512-eiCFS+e6DV9UTWFX5eINQCbXHi2w5Wn9SXtAOHZa4PIXOzxXKVJL/uXE/c6TfCVqhrsDdzYwrtt9koi5qmYXCg=="], + + "@intx/workflow-host": ["@intx/workflow-host@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/crypto": "0.3.0", "@intx/hub-sessions": "0.3.0", "@intx/inference": "0.3.0", "@intx/log": "0.3.0", "@intx/mail-memory": "0.3.0", "@intx/mime": "0.3.0", "@intx/storage-isogit": "0.3.0", "@intx/types": "0.3.0", "@intx/workflow": "0.3.0", "arktype": "^2.1.29" } }, "sha512-vhujPnhCrf2RensenRPSH63SQZlAvtgwLyZeNWSUHlNJ94g72xzaYAa/eYcc9OHGGdGHVo63OFQ1oS1j3/kORg=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@isomorphic-git/idb-keyval": ["@isomorphic-git/idb-keyval@3.3.2", "", {}, "sha512-r8/AdpiS0/WJCNR/t/gsgL+M8NMVj/ek7s60uz3LmpCaTF2mEVlZJlB01ZzalgYzRLXwSPC92o+pdzjM7PN/pA=="], "@isomorphic-git/lightning-fs": ["@isomorphic-git/lightning-fs@4.7.0", "", { "dependencies": { "@isomorphic-git/idb-keyval": "3.3.2", "isomorphic-textencoder": "1.0.1", "just-debounce-it": "1.1.0", "just-once": "1.1.0" }, "bin": { "superblocktxt": "src/superblocktxt.js" } }, "sha512-eJg541itXKCOyj3DBAnd0KNY1mNgi29GCpy7FgKWsgatu/ulEKv+dMxxjhUNL68Jh3uPTaGNfeAjdJiHUYEGnw=="], @@ -227,6 +297,12 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], + + "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], + + "@npmcli/redact": ["@npmcli/redact@4.0.0", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="], + "@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="], @@ -283,6 +359,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -317,6 +395,8 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -327,6 +407,8 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -363,6 +445,8 @@ "diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="], + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -439,6 +523,8 @@ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], @@ -447,6 +533,8 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -467,8 +555,16 @@ "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], + "hosted-git-info": ["hosted-git-info@8.1.0", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -515,6 +611,8 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], + "just-debounce-it": ["just-debounce-it@1.1.0", "", {}, "sha512-87Nnc0qZKgBZuhFZjYVjSraic0x7zwjhaTMrCKlj0QYKH6lh0KbFzVnfu6LHan03NO7J8ygjeBeD0epejn5Zcg=="], "just-once": ["just-once@1.1.0", "", {}, "sha512-+rZVpl+6VyTilK7vB/svlMPil4pxqIJZkbnN7DKZTOzyXfun6ZiFeq2Pk4EtCEHZ0VU4EkdFzG8ZK5F3PErcDw=="], @@ -527,6 +625,10 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -547,12 +649,36 @@ "minimisted": ["minimisted@2.0.1", "", { "dependencies": { "minimist": "^1.2.5" } }, "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@5.0.2", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "npm-install-checks": ["npm-install-checks@7.1.2", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-z9HJBCYw9Zr8BqXcllKIs5nI+QggAImbBdHphOzVYrz2CB4iQ6FzWyKmlqDZua+51nAu7FcemlbTc9VgQN5XDQ=="], + + "npm-normalize-package-bin": ["npm-normalize-package-bin@4.0.0", "", {}, "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w=="], + + "npm-package-arg": ["npm-package-arg@12.0.2", "", { "dependencies": { "hosted-git-info": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^6.0.0" } }, "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA=="], + + "npm-pick-manifest": ["npm-pick-manifest@10.0.0", "", { "dependencies": { "npm-install-checks": "^7.1.0", "npm-normalize-package-bin": "^4.0.0", "npm-package-arg": "^12.0.0", "semver": "^7.3.5" } }, "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ=="], + + "npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -567,6 +693,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@7.0.7", "", {}, "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -577,6 +705,8 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], @@ -587,10 +717,14 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -643,6 +777,14 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -655,6 +797,8 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], @@ -681,6 +825,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "validate-npm-package-name": ["validate-npm-package-name@6.0.2", "", {}, "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vscode-jsonrpc": ["vscode-jsonrpc@9.0.1", "", {}, "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw=="], @@ -699,6 +845,8 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -709,6 +857,8 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], @@ -717,8 +867,28 @@ "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "cacache/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "cacache/ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "glob/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "make-fetch-happen/proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "make-fetch-happen/ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "npm-registry-fetch/npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="], + + "npm-registry-fetch/proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], @@ -731,6 +901,20 @@ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "npm-registry-fetch/npm-package-arg/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "npm-registry-fetch/npm-package-arg/validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "npm-registry-fetch/npm-package-arg/hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], } } diff --git a/docs/VENDORING.md b/docs/VENDORING.md index ddc4bf858..d0b925563 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -30,6 +30,62 @@ points straight at `./src/*.ts` files rather than a `dist/` build. | `@intx/authz` | `vendor/intx-authz/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | | `@intx/log` | `vendor/intx-log/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | | `@intx/tools-posix` | `vendor/intx-tools-posix/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| `@intx/mailbox` | `vendor/intx-mailbox/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| `@intx/harness` | `vendor/intx-harness/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| `@intx/mime` | `vendor/intx-mime/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | + +## Provenance, ownership, and kill dates + +Every vendored path with its upstream source, why the published npm package +did not cover the need, its owner, and its kill date. A kill date is a +proposal the operator ratifies on review; each ties an observable condition +to a hard backstop date (2027-03-07, six months after this sync). When the +condition is met the vendored tree is dropped in favour of the published +package; the date is the deadline even if it is not. + +| Vendor path | Upstream repo | Upstream commit | Patched | Why not the published package | Owner | Proposed kill date | +| ------------------------------------- | ----------------------- | ------------------------------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ | +| `vendor/intx-inference/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | Yes — `PATCHES.md` | Local fixes not yet upstream | runtime | 2027-03-07 or when patches land upstream and publish | +| `vendor/intx-types/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Cross-package coupling with `@intx/inference` | runtime | 2027-03-07 or when the coupled trio publishes past `0.3.0` | +| `vendor/intx-storage-isogit/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Cross-package coupling with `@intx/inference` | runtime | 2027-03-07 or when the coupled trio publishes past `0.3.0` | +| `vendor/intx-agent/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/agent@>=0.4.0` publishes | +| `vendor/intx-authz/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/authz@>=0.4.0` publishes | +| `vendor/intx-log/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/log@>=0.4.0` publishes | +| `vendor/intx-tools-posix/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/tools-posix@>=0.4.0` publishes | +| `vendor/intx-mailbox/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Never published to npm (verified 2026-09-07: registry 404 for all versions) | step-1 | 2027-03-07 or when `@intx/mailbox@>=0.4.0` publishes | +| `vendor/intx-harness/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | `driveConnectorReplies`/`AgentEventStream` (`src/reply-drain.ts`) is past npm `0.3.0` (verified 2026-09-07: absent from the published tarball) | step-1 | 2027-03-07 or when a published `@intx/harness` exports `driveConnectorReplies` | +| `vendor/intx-mime/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | `buildMessageHeaders` is past npm `0.3.0` (verified 2026-09-07: absent from the published tarball); `@intx/mailbox` re-exports it | step-1 | 2027-03-07 or when a published `@intx/mime` exports `buildMessageHeaders` | +| `vendor/intx-workflow-host/adapters/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | App-internal: `substrate-mailbox-store.ts` has never been published in any `@intx/workflow-host` release (verified 2026-09-07: absent from the `0.3.0` tarball) | step-1 | 2027-03-07 or when a published `@intx/workflow-host` exports `createSubstrateMailboxStore` | + +### The 2026-09-07 step-1 vendor pass + +Three needs from the ticket were verified against npm and found already +covered by published packages, so they are **dependencies, not vendored +trees**: + +- `@intx/hub-sessions` `./substrate` — `createAgentRepoStore({ dataDir, +signingKey })` (local disk + keypair, no hub, no database) is present in + the published `0.3.0` tarball. Root `dependencies` pins `0.3.0`. +- `@intx/mail-memory` — `InMemoryTransport`/`createInMemoryTransport` are + present in the published `0.3.0`. Root `dependencies` pins `0.3.0`. +- the `@intx/workflow onEvent` seam — the `opts.onEvent` sink on + `createWorkflowStepInvoker` lives in `@intx/workflow-host`'s + `adapters/step-invoker.ts`, and the published `@intx/workflow-host@0.3.0` + tarball already carries it (its `subscribeAgentEvents(agent, +opts.onEvent)` wiring). Root `dependencies` pins `0.3.0`. + +Everything vendored in this pass sits at the same upstream commit +`0205b07b64d03f0fec2e4be3593c764070a9ba8a` as the existing trees; no +vendored tree mixes pins. + +`vendor/intx-workflow-host/adapters/` is a partial-package vendor: upstream +`packages/workflow-host` has a `package.json`, but this tree carries only +the never-published `substrate-mailbox-store` adapter (source + its test, +563 lines, documented on-disk layout, O(delta) flushes). It is +deliberately **not** a workspace member and nothing in `src/` imports it — +Step 1 decides whether to wire it as a package when it consumes it. Its +`@intx/hub-sessions/substrate` and `@intx/mailbox` imports resolve once +that wiring exists; until then it is inert provenance, not dead weight. The 2026-09-07 sync moved all three packages together to a single upstream commit, restoring the single-commit coherence the coupling rule @@ -65,7 +121,7 @@ copies. `@intx/tools-lsp` remains on published npm (`0.3.0`) — it is a thin adapter whose transitive `@intx/*` dependencies resolve to the vendored workspaces via root `overrides`, so it tracks the vendored set without being vendored itself. Published transitive dependencies that -stay on npm (`@intx/mime`, `@intx/crypto`, `@intx/inference-discovery`, +stay on npm (`@intx/crypto`, `@intx/inference-discovery`, `@intx/inference-testing`) are pinned to the root's published versions so the lockfile never nests duplicate copies of them either. @@ -150,20 +206,18 @@ behavioral one. The package also picked up two new dependencies (`@isomorphic-git/lightning-fs`, `buffer`, both used only by the new `./browser` runtime, which nothing here imports) and a new `@intx/crypto` dev dependency for its own test suite, pinned to `0.2.2` — -the same "stay on published npm for a package we don't vendor" pattern as the -`@intx/log` and `@intx/mime` dependencies on the other vendored packages, -which are kept aligned with the root's published `0.3.0` pins so the -lockfile never nests duplicate copies. +the same "stay on published npm for a package we don't vendor" pattern as +the `@intx/log` dependency on the other vendored packages; `@intx/mime` +joined the vendored set in the 2026-09-07 step-1 pass, and its consumers +now resolve it through the root `workspace:*` override. One new upstream test, `browser-bundle.test.ts`, is excluded via `bunfig.toml`'s `pathIgnorePatterns`. It bundles `browser.ts` with `Bun.build` under the `intx-src` export condition, which resolves -`@intx/log` and `@intx/mime` to `./src/*.ts`. `@intx/log` is vendored -source since the 2026-09-07 sync, but `@intx/mime` remains a published -npm install (`dist/` only, no `src/`), so the condition still matches an -export key whose target does not exist and the bundle fails to resolve. -This is an environment gap, not a defect in the vendored code; re-check -it when `@intx/mime` gets vendored too. +`@intx/log` and `@intx/mime` to `./src/*.ts`. Both are vendored source as +of the 2026-09-07 syncs (`@intx/mime` in the step-1 pass, which vendored +it for `buildMessageHeaders`), so the condition has a `src/` target again; +the test stays excluded pending a re-run of the bundle in CI conditions. `@intx/inference` carries local patches — real fixes not yet present upstream, not workarounds for something upstream has since fixed. Every diff --git a/package.json b/package.json index 8bda771fa..ab187eb13 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,9 @@ "./vendor/intx-authz", "./vendor/intx-log", "./vendor/intx-tools-posix", + "./vendor/intx-mailbox", + "./vendor/intx-harness", + "./vendor/intx-mime", "./packages/*" ], "overrides": { @@ -59,7 +62,10 @@ "@intx/agent": "workspace:*", "@intx/authz": "workspace:*", "@intx/log": "workspace:*", - "@intx/tools-posix": "workspace:*" + "@intx/tools-posix": "workspace:*", + "@intx/mailbox": "workspace:*", + "@intx/harness": "workspace:*", + "@intx/mime": "workspace:*" }, "catalog": { "@types/semver": "^7.7.1", @@ -80,6 +86,12 @@ "@intx/storage-isogit": "workspace:*", "@intx/tools-lsp": "0.3.0", "@intx/tools-posix": "workspace:*", + "@intx/mailbox": "workspace:*", + "@intx/harness": "workspace:*", + "@intx/mime": "workspace:*", + "@intx/mail-memory": "0.3.0", + "@intx/hub-sessions": "0.3.0", + "@intx/workflow-host": "0.3.0", "@intx/types": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentui/core": "0.5.10", diff --git a/vendor/intx-agent/package.json b/vendor/intx-agent/package.json index cb1983df7..48cd36613 100644 --- a/vendor/intx-agent/package.json +++ b/vendor/intx-agent/package.json @@ -16,7 +16,7 @@ "dependencies": { "@intx/inference": "workspace:*", "@intx/log": "workspace:*", - "@intx/mime": "0.3.0", + "@intx/mime": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:" }, diff --git a/vendor/intx-harness/LICENSE b/vendor/intx-harness/LICENSE new file mode 100644 index 000000000..c6487f4fd --- /dev/null +++ b/vendor/intx-harness/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/vendor/intx-harness/README.md b/vendor/intx-harness/README.md new file mode 100644 index 000000000..4db017a6f --- /dev/null +++ b/vendor/intx-harness/README.md @@ -0,0 +1,71 @@ +# @intx/harness + +Composition layer over `@intx/agent` that adds the mail-transport +surface: INBOX watch, connector router, connector-reply outbound +forwarding, and connector-state persistence layered on top of the +agent's context store. The reactor is wrapped exactly once -- inside +`@intx/agent`'s `createAgent` -- and the harness composes around that. + +Consumed by `apps/sidecar` for sidecar-hosted agents and by demo +examples that need a transport-bearing agent. + +```ts +import { + createDefaultDirectorRegistry, + defineAgent, + defineTool, +} from "@intx/agent"; +import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; +import { createHarness, defineMailTools } from "@intx/harness"; +import { createIsogitStore } from "@intx/storage-isogit/node"; + +const mailFactory = defineMailTools( + () => ({ + definitions: myMailTools.definitions, + run: (call, signal) => myMailTools.run(call, signal), + }), + myMailTools.definitions.map((def) => ({ name: def.name })), +); + +const posixFactory = defineTool({ + id: "@my-org/agent/posix", + definitions: myPosixTools.definitions.map((def) => ({ name: def.name })), + factory: () => ({ + definitions: myPosixTools.definitions, + run: (call, signal) => myPosixTools.run(call, signal), + }), +}); + +const def = defineAgent({ + id: "agent@tenant.interchange.network", + systemPrompt, + tools: [mailFactory, posixFactory], + capabilities: [], + inference: { sources: [{ provider: source.provider, model: source.model }] }, +}); + +const harness = await createHarness(def, { + source, + storage: await createIsogitStore(workdir), + workdir, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDefaultDirectorRegistry(), + transport, + address: "agent@tenant.interchange.network", +}); + +// Subscribe to events via harness.stream(); compose with your own +// downstream observability sink. +for await (const event of harness.stream()) { + // ... +} + +await harness.close(); +``` + +The narrowed `Harness` surface is `close()`, `deliver(message)`, +`setSource(source)`, `stream()`, and `blobReader`. Tool composition +flows through `defineMailTools` and `defineTool` from `@intx/agent`; +the agent's own `resolveTools` aggregates definitions and dispatches +calls. diff --git a/vendor/intx-harness/package.json b/vendor/intx-harness/package.json new file mode 100644 index 000000000..e865b39d7 --- /dev/null +++ b/vendor/intx-harness/package.json @@ -0,0 +1,24 @@ +{ + "name": "@intx/harness", + "version": "0.2.2", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "dependencies": { + "@intx/agent": "workspace:*", + "@intx/authz": "workspace:*", + "@intx/log": "workspace:*", + "@intx/types": "workspace:*" + }, + "devDependencies": { + "@intx/inference-testing": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/storage-isogit": "workspace:*", + "arktype": "catalog:" + } +} diff --git a/vendor/intx-harness/src/connector-router.test.ts b/vendor/intx-harness/src/connector-router.test.ts new file mode 100644 index 000000000..0e8d8abc1 --- /dev/null +++ b/vendor/intx-harness/src/connector-router.test.ts @@ -0,0 +1,718 @@ +import { describe, test, expect } from "bun:test"; + +import { createInboundMessage } from "@intx/mime"; +import type { ConnectorThreadState, InboundMessage } from "@intx/types/runtime"; + +import { + createConnectorRouter, + NoActiveConnectorThreadError, +} from "./connector-router"; + +function startMessage(opts?: { subject?: string }): InboundMessage { + return createInboundMessage({ + from: "user@example.com", + to: ["agent@example.com"], + content: "hello", + messageId: "", + ...(opts?.subject !== undefined ? { subject: opts.subject } : {}), + }); +} + +function continuationByReferences( + threadRoot: string, + opts?: { from?: string; messageId?: string }, +): InboundMessage { + return createInboundMessage({ + from: opts?.from ?? "user@example.com", + to: ["agent@example.com"], + content: "more", + messageId: opts?.messageId ?? "", + references: [threadRoot], + }); +} + +function continuationByInReplyTo( + lastMessageId: string, + opts?: { from?: string; messageId?: string }, +): InboundMessage { + return createInboundMessage({ + from: opts?.from ?? "user@example.com", + to: ["agent@example.com"], + content: "more", + messageId: opts?.messageId ?? "", + inReplyTo: lastMessageId, + }); +} + +function unrelatedMessage(): InboundMessage { + return createInboundMessage({ + from: "stranger@example.com", + to: ["agent@example.com"], + content: "unrelated", + messageId: "", + }); +} + +describe("createConnectorRouter", () => { + describe("route + commit (inbound)", () => { + test("no active thread: start initializes with empty cc", () => { + const router = createConnectorRouter(); + + router.commit(router.route(startMessage({ subject: "Hello" }))); + + expect(router.snapshot()).toEqual({ + threadRoot: "", + lastMessageId: "", + replyTo: "user@example.com", + cc: [], + subject: "Hello", + }); + }); + + test("active thread + references includes threadRoot: continue from the same sender keeps cc empty", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage({ subject: "Hello" }))); + + router.commit( + router.route( + continuationByReferences("", { + from: "user@example.com", + messageId: "", + }), + ), + ); + + expect(router.snapshot()).toEqual({ + threadRoot: "", + lastMessageId: "", + replyTo: "user@example.com", + cc: [], + subject: "Hello", + }); + }); + + test("active thread + inReplyTo equals lastMessageId: continue from the same sender keeps cc empty", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage())); + + router.commit( + router.route( + continuationByInReplyTo("", { + messageId: "", + }), + ), + ); + + expect(router.snapshot()).toEqual({ + threadRoot: "", + lastMessageId: "", + replyTo: "user@example.com", + cc: [], + }); + }); + + test("continue from a different sender moves the prior replyTo into cc", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage({ subject: "Important" }))); + + router.commit( + router.route( + continuationByReferences("", { + from: "other@example.com", + messageId: "", + }), + ), + ); + + expect(router.snapshot()).toEqual({ + threadRoot: "", + lastMessageId: "", + replyTo: "other@example.com", + cc: ["user@example.com"], + subject: "Important", + }); + }); + + test("continue accumulates participants across multiple distinct senders", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage({ subject: "Important" }))); + + router.commit( + router.route( + continuationByReferences("", { + from: "second@example.com", + messageId: "", + }), + ), + ); + router.commit( + router.route( + continuationByReferences("", { + from: "third@example.com", + messageId: "", + }), + ), + ); + + const snap = router.snapshot(); + expect(snap?.replyTo).toBe("third@example.com"); + expect(snap?.cc).toEqual(["user@example.com", "second@example.com"]); + }); + + test("continue from a sender already in cc does not duplicate them", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage())); + router.commit( + router.route( + continuationByReferences("", { + from: "second@example.com", + messageId: "", + }), + ), + ); + // Now: replyTo=second, cc=[user]. The original user returns. + router.commit( + router.route( + continuationByReferences("", { + from: "user@example.com", + messageId: "", + }), + ), + ); + + const snap = router.snapshot(); + expect(snap?.replyTo).toBe("user@example.com"); + // user is now the most recent speaker; second is the only other + // participant. user must not appear in cc. + expect(snap?.cc).toEqual(["second@example.com"]); + }); + + test("subject is preserved across many continues from different senders", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage({ subject: "Important" }))); + + for (const from of ["b@example.com", "c@example.com", "d@example.com"]) { + router.commit( + router.route( + continuationByReferences("", { + from, + messageId: ``, + }), + ), + ); + } + + expect(router.snapshot()?.subject).toBe("Important"); + }); + + test("active thread + neither header rule matches: passthrough leaves state unchanged", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage({ subject: "Hello" }))); + const before = router.snapshot(); + + const decision = router.route(unrelatedMessage()); + expect(decision.kind).toBe("passthrough"); + + router.commit(decision); + + expect(router.snapshot()).toEqual(before); + }); + + test("commit on a foreign decision throws", () => { + const router = createConnectorRouter(); + expect(() => { + router.commit({ kind: "start" }); + }).toThrow(); + }); + }); + + describe("composeReply (outbound)", () => { + test("single-participant thread: cc is empty", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage({ subject: "Hello" }))); + + const parts = router.composeReply(); + expect(parts).toEqual({ + to: "user@example.com", + cc: [], + inReplyTo: "", + subject: "Hello", + }); + }); + + test("multi-participant thread: cc contains all prior speakers", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage({ subject: "Important" }))); + router.commit( + router.route( + continuationByReferences("", { + from: "second@example.com", + messageId: "", + }), + ), + ); + router.commit( + router.route( + continuationByReferences("", { + from: "third@example.com", + messageId: "", + }), + ), + ); + + const parts = router.composeReply(); + expect(parts).toEqual({ + to: "third@example.com", + cc: ["user@example.com", "second@example.com"], + inReplyTo: "", + subject: "Important", + }); + }); + + test("active thread without subject: subject key absent (not undefined)", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage())); + + const parts = router.composeReply(); + expect(parts.to).toBe("user@example.com"); + expect(parts.cc).toEqual([]); + expect(parts.inReplyTo).toBe(""); + expect("subject" in parts).toBe(false); + }); + + test("composeReply returns a copy of cc, not the live array", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage())); + router.commit( + router.route( + continuationByReferences("", { + from: "second@example.com", + messageId: "", + }), + ), + ); + + const parts = router.composeReply(); + parts.cc.push("injected@example.com"); + + // Subsequent state must not include the injected value. + expect(router.snapshot()?.cc).toEqual(["user@example.com"]); + }); + + test("no active thread: throws NoActiveConnectorThreadError", () => { + const router = createConnectorRouter(); + expect(() => { + router.composeReply(); + }).toThrow(NoActiveConnectorThreadError); + }); + + test("onReplySent advances lastMessageId and preserves cc", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage({ subject: "Hello" }))); + router.commit( + router.route( + continuationByReferences("", { + from: "second@example.com", + messageId: "", + }), + ), + ); + + router.onReplySent({ + messageId: "", + status: "delivered", + }); + + expect(router.snapshot()).toEqual({ + threadRoot: "", + lastMessageId: "", + replyTo: "second@example.com", + cc: ["user@example.com"], + subject: "Hello", + }); + + // A subsequent inbound whose inReplyTo matches the new + // lastMessageId must route as continue. + const followup = continuationByInReplyTo("", { + from: "second@example.com", + messageId: "", + }); + expect(router.route(followup).kind).toBe("continue"); + }); + + test("onReplySent with no active thread throws", () => { + const router = createConnectorRouter(); + expect(() => { + router.onReplySent({ + messageId: "", + status: "delivered", + }); + }).toThrow(NoActiveConnectorThreadError); + }); + }); + + describe("snapshot/restore round-trip", () => { + test("a router restored from a snapshot decides identically", () => { + const a = createConnectorRouter(); + a.commit(a.route(startMessage({ subject: "Hello" }))); + a.commit( + a.route( + continuationByReferences("", { + from: "other@example.com", + messageId: "", + }), + ), + ); + + const snap = a.snapshot(); + + const b = createConnectorRouter(); + b.restore(snap); + + expect(b.snapshot()).toEqual(snap); + + const probe = continuationByInReplyTo("", { + messageId: "", + }); + expect(b.route(probe).kind).toBe(a.route(probe).kind); + + // Outbound parts also match (including cc). + expect(b.composeReply()).toEqual(a.composeReply()); + }); + + test("restore(null) clears active thread", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage())); + expect(router.snapshot()).not.toBeNull(); + + router.restore(null); + expect(router.snapshot()).toBeNull(); + + expect(router.route(unrelatedMessage()).kind).toBe("start"); + }); + + test("snapshot is a copy, not the live state", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage({ subject: "Hello" }))); + + const snap = router.snapshot(); + if (snap === null) throw new Error("expected non-null snapshot"); + + router.commit( + router.route( + continuationByReferences("", { + messageId: "", + }), + ), + ); + + expect(snap.lastMessageId).toBe(""); + expect(router.snapshot()?.lastMessageId).toBe( + "", + ); + }); + + test("restore takes a defensive copy of the input state and its cc", () => { + const router = createConnectorRouter(); + const input: ConnectorThreadState = { + threadRoot: "", + lastMessageId: "", + replyTo: "x@example.com", + cc: ["a@example.com"], + subject: "S", + }; + router.restore(input); + + input.lastMessageId = ""; + input.cc.push("injected@example.com"); + + const snap = router.snapshot(); + expect(snap?.lastMessageId).toBe(""); + expect(snap?.cc).toEqual(["a@example.com"]); + }); + }); + + describe("replyTo normalization", () => { + // These tests bypass createInboundMessage because its `from` validator + // requires a bare addr-spec, but on the production fetch path + // (mail-memory's buildMessageHeaders) the `from` header is copied + // verbatim from the wire and may contain a display name. The router + // is the layer that has to handle that, so the test exercises it + // with wire-shaped values. + function inboundWith( + fromHeader: string, + messageId: string, + ): InboundMessage { + return { + ref: { uid: 1, mailbox: "INBOX" }, + headers: { + from: fromHeader, + to: ["agent@example.com"], + date: new Date().toISOString(), + messageId, + }, + flags: [], + content: "x", + signatureStatus: "missing", + }; + } + + test("strips display name and lowercases when storing replyTo on start", () => { + const router = createConnectorRouter(); + + router.commit( + router.route( + inboundWith('"Alice Doe" ', ""), + ), + ); + + expect(router.snapshot()?.replyTo).toBe("alice@example.com"); + }); + + test("strips display name when advancing replyTo on continue", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage({ subject: "Hello" }))); + + const followup: InboundMessage = { + ref: { uid: 2, mailbox: "INBOX" }, + headers: { + from: '"Other User" ', + to: ["agent@example.com"], + date: new Date().toISOString(), + messageId: "", + references: [""], + }, + flags: [], + content: "more", + signatureStatus: "missing", + }; + router.commit(router.route(followup)); + + expect(router.snapshot()?.replyTo).toBe("other@example.com"); + }); + + test("route() throws when the from header is unparseable on start", () => { + const router = createConnectorRouter(); + const message = inboundWith("not-an-address", ""); + + expect(() => router.route(message)).toThrow(); + }); + + test("route() throws when the from header is unparseable on continue", () => { + const router = createConnectorRouter(); + router.commit(router.route(startMessage())); + + const malformedContinuation: InboundMessage = { + ref: { uid: 99, mailbox: "INBOX" }, + headers: { + from: "not-an-address", + to: ["agent@example.com"], + date: new Date().toISOString(), + messageId: "", + references: [""], + }, + flags: [], + content: "more", + signatureStatus: "missing", + }; + + expect(() => router.route(malformedContinuation)).toThrow(); + }); + }); + + describe("onStateChanged callback", () => { + test("fires after a start decision commits", () => { + const events: (ConnectorThreadState | null)[] = []; + const router = createConnectorRouter({ + onStateChanged: (s) => events.push(s), + }); + + router.commit(router.route(startMessage({ subject: "Hello" }))); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + threadRoot: "", + lastMessageId: "", + replyTo: "user@example.com", + cc: [], + subject: "Hello", + }); + }); + + test("fires after continue advances the thread", () => { + const events: (ConnectorThreadState | null)[] = []; + const router = createConnectorRouter({ + onStateChanged: (s) => events.push(s), + }); + router.commit(router.route(startMessage())); + events.length = 0; + + router.commit( + router.route( + continuationByReferences("", { + from: "second@example.com", + messageId: "", + }), + ), + ); + + expect(events).toHaveLength(1); + expect(events[0]?.lastMessageId).toBe(""); + expect(events[0]?.replyTo).toBe("second@example.com"); + expect(events[0]?.cc).toEqual(["user@example.com"]); + }); + + test("does not fire for passthrough commits", () => { + const events: (ConnectorThreadState | null)[] = []; + const router = createConnectorRouter({ + onStateChanged: (s) => events.push(s), + }); + router.commit(router.route(startMessage())); + events.length = 0; + + router.commit(router.route(unrelatedMessage())); + + expect(events).toHaveLength(0); + }); + + test("fires after onReplySent advances lastMessageId", () => { + const events: (ConnectorThreadState | null)[] = []; + const router = createConnectorRouter({ + onStateChanged: (s) => events.push(s), + }); + router.commit(router.route(startMessage())); + events.length = 0; + + router.onReplySent({ + messageId: "", + status: "delivered", + }); + + expect(events).toHaveLength(1); + expect(events[0]?.lastMessageId).toBe(""); + }); + + test("fires on restore() when state changes from null", () => { + const events: (ConnectorThreadState | null)[] = []; + const router = createConnectorRouter({ + onStateChanged: (s) => events.push(s), + }); + + router.restore({ + threadRoot: "", + lastMessageId: "", + replyTo: "r@example.com", + cc: [], + }); + + expect(events).toHaveLength(1); + expect(events[0]?.threadRoot).toBe(""); + }); + + test("does not fire on restore(null) when already null (cold start)", () => { + const events: (ConnectorThreadState | null)[] = []; + const router = createConnectorRouter({ + onStateChanged: (s) => events.push(s), + }); + + router.restore(null); + + expect(events).toHaveLength(0); + }); + + test("does not fire on restore() into an equal state", () => { + const events: (ConnectorThreadState | null)[] = []; + const router = createConnectorRouter({ + onStateChanged: (s) => events.push(s), + }); + const snap: ConnectorThreadState = { + threadRoot: "", + lastMessageId: "", + replyTo: "r@example.com", + cc: ["a@example.com"], + subject: "S", + }; + router.restore(snap); + events.length = 0; + + router.restore({ ...snap, cc: [...snap.cc] }); + + expect(events).toHaveLength(0); + }); + + test("fires when cc changes even if replyTo and lastMessageId do not", () => { + // Defensive: any field of the state changing is a state change. + const events: (ConnectorThreadState | null)[] = []; + const router = createConnectorRouter({ + onStateChanged: (s) => events.push(s), + }); + const snap: ConnectorThreadState = { + threadRoot: "", + lastMessageId: "", + replyTo: "r@example.com", + cc: [], + }; + router.restore(snap); + events.length = 0; + + router.restore({ ...snap, cc: ["a@example.com"] }); + + expect(events).toHaveLength(1); + expect(events[0]?.cc).toEqual(["a@example.com"]); + }); + + test("fires with a snapshot copy, not the live state", () => { + const events: (ConnectorThreadState | null)[] = []; + const router = createConnectorRouter({ + onStateChanged: (s) => events.push(s), + }); + + router.commit(router.route(startMessage())); + const captured = events[0]; + if (captured === null || captured === undefined) { + throw new Error("expected non-null captured state"); + } + + router.onReplySent({ + messageId: "", + status: "delivered", + }); + + expect(captured.lastMessageId).toBe(""); + }); + + test("a throwing subscriber does not propagate out of commit()", () => { + const router = createConnectorRouter({ + onStateChanged: () => { + throw new Error("subscriber boom"); + }, + }); + + const decision = router.route(startMessage()); + expect(() => router.commit(decision)).not.toThrow(); + expect(router.snapshot()).not.toBeNull(); + }); + + test("a throwing subscriber does not propagate out of onReplySent()", () => { + let firstCall = true; + const router = createConnectorRouter({ + onStateChanged: () => { + if (firstCall) { + firstCall = false; + return; + } + throw new Error("subscriber boom"); + }, + }); + router.commit(router.route(startMessage())); + + expect(() => + router.onReplySent({ + messageId: "", + status: "delivered", + }), + ).not.toThrow(); + expect(router.snapshot()?.lastMessageId).toBe(""); + }); + }); +}); diff --git a/vendor/intx-harness/src/connector-router.ts b/vendor/intx-harness/src/connector-router.ts new file mode 100644 index 000000000..b7f8009a2 --- /dev/null +++ b/vendor/intx-harness/src/connector-router.ts @@ -0,0 +1,304 @@ +// Connector-thread routing for the agent harness. +// +// The connector is one durable thread per agent. Participants accumulate +// as they speak; no one is displaced. `replyTo` tracks the most recent +// speaker (the primary recipient on the next outbound reply) and `cc` +// tracks every other participant who has spoken (carried on outbound so +// everyone stays in the loop). +// +// Two-phase decision: route() is pure and returns a discriminated kind +// plus an opaque carrier of the next state; commit() advances router +// state from that carrier. Separating the decision from the mutation +// lets the harness sequence the side effects (deliver, INBOX expunge) +// around the state change however it needs to. + +import { getLogger } from "@intx/log"; +import { extractAddrSpec } from "@intx/mime"; +import type { + ConnectorThreadState, + InboundMessage, + SendReceipt, +} from "@intx/types/runtime"; + +const logger = getLogger(["interchange", "harness", "connector-router"]); + +export type RouteDecision = + | { kind: "start" } + | { kind: "continue" } + | { kind: "passthrough" }; + +export type ConnectorReplyParts = { + to: string; + cc: string[]; + inReplyTo: string; + subject?: string; +}; + +export class NoActiveConnectorThreadError extends Error { + constructor() { + super("no active connector thread"); + this.name = "NoActiveConnectorThreadError"; + } +} + +export type ConnectorRouterOptions = { + /** + * Called synchronously after the router's internal state mutates and the + * new state is committed to internal storage. Fires only when the new + * state differs from the prior state — restore() into the same state, + * passthrough commits, and other no-ops do not fire. Single subscriber: + * the harness wiring that lifts state changes onto the hub-bound event + * channel. + * + * The router catches and logs any error this callback throws. The cache + * the callback feeds is a best-effort projection of router state, and + * the authoritative state remains in the router and the persisted + * context store. Dropping one notification means the projection stays + * stale until the next state change rebuilds it; that is the right + * trade-off versus aborting the call chain that invoked the + * commit/onReplySent that produced the notification. + */ + onStateChanged?(state: ConnectorThreadState | null): void; +}; + +export interface ConnectorRouter { + /** + * Classify an inbound message against the current connector state. Pure: + * does not mutate router state. The returned decision must be passed to + * `commit()` to take effect. + * + * Throws when `message.headers.from` is not a parseable bare addr-spec + * (per `extractAddrSpec` from `@intx/mime`). The production fetch path + * copies the wire `From:` header verbatim, so a malformed sender is a + * normal-shape runtime concern, not a programmer error. Callers should + * treat the throw as passthrough — deliver the message to the reactor + * but do not advance router state or consume the message from the + * INBOX. + */ + route(message: InboundMessage): RouteDecision; + + /** + * Advance router state per a decision produced by `route()`. No-op for + * `passthrough`. For `start` and `continue`, throws if the decision was + * not produced by this router instance. + */ + commit(decision: RouteDecision): void; + + /** + * Produce the threading headers needed to send a reply on the active + * connector thread. `to` is the most recent speaker; `cc` is everyone + * else who has spoken on the thread (deduplicated). The caller composes + * the full outbound message by adding its own `content` and `type` + * fields. Throws `NoActiveConnectorThreadError` when no thread is + * active. + */ + composeReply(): ConnectorReplyParts; + + /** + * Update `lastMessageId` after a successful outbound reply send. + * Throws when called with no active thread — outbound state advance + * has no meaning without a thread. + */ + onReplySent(receipt: SendReceipt): void; + + /** + * Return the current connector state as a serializable snapshot, or + * `null` when no thread is active. Matches the + * `ConnectorThreadState | null` shape used by the storage layer. + */ + snapshot(): ConnectorThreadState | null; + + /** + * Install a snapshot as the router's current state. Used at startup + * to restore from the persisted context store, and in tests to set + * up scenarios. Passing `null` clears the active thread. + */ + restore(state: ConnectorThreadState | null): void; +} + +function statesEqual( + a: ConnectorThreadState | null, + b: ConnectorThreadState | null, +): boolean { + if (a === null || b === null) return a === b; + return ( + a.threadRoot === b.threadRoot && + a.lastMessageId === b.lastMessageId && + a.replyTo === b.replyTo && + a.subject === b.subject && + a.cc.length === b.cc.length && + a.cc.every((v, i) => v === b.cc[i]) + ); +} + +export function createConnectorRouter( + options?: ConnectorRouterOptions, +): ConnectorRouter { + let state: ConnectorThreadState | null = null; + const onStateChanged = options?.onStateChanged; + + // Pending state per decision is held off the decision object via a + // WeakMap so callers see only `{ kind }` — no path to inspect or + // mutate the next state, even via type assertions. + const pendingStates = new WeakMap(); + + function applyState(next: ConnectorThreadState | null): void { + // The null → X transition is what drives bootstrap on restore() — a + // future refactor that collapses null into a sentinel "no mutation" + // case would silently break the hub-side cache's only fill path + // outside live state mutations. Keep the equality check as-is; the + // null state is a value, not a non-event. + if (statesEqual(state, next)) return; + state = next; + if (onStateChanged !== undefined) { + // The callback feeds a best-effort projection of router state. A + // throwing subscriber would otherwise propagate out of commit() or + // onReplySent() and abort the caller; catching here drops one + // notification (cache stays stale until the next change) instead + // of corrupting the call chain. The authoritative state is + // already committed to the router by this point. + try { + onStateChanged(snapshot()); + } catch (cause) { + logger.warn`onStateChanged subscriber threw: ${cause instanceof Error ? cause.message : String(cause)}`; + } + } + } + + function isContinuation(message: InboundMessage): boolean { + if (state === null) return false; + + const { inReplyTo, references } = message.headers; + + if (references !== undefined && references.includes(state.threadRoot)) { + return true; + } + + if (inReplyTo !== undefined && inReplyTo === state.lastMessageId) { + return true; + } + + return false; + } + + // Append `value` to `existing` only when it is not already present. + // The thread's participant list is small enough that linear-scan dedup + // is the right cost. + function appendUnique(existing: readonly string[], value: string): string[] { + if (existing.includes(value)) return [...existing]; + return [...existing, value]; + } + + function route(message: InboundMessage): RouteDecision { + if (state === null) { + const nextState: ConnectorThreadState = { + threadRoot: message.headers.messageId, + lastMessageId: message.headers.messageId, + replyTo: extractAddrSpec(message.headers.from), + cc: [], + ...(message.headers.subject !== undefined + ? { subject: message.headers.subject } + : {}), + }; + const decision: RouteDecision = { kind: "start" }; + pendingStates.set(decision, nextState); + return decision; + } + + if (isContinuation(message)) { + const nextSpeaker = extractAddrSpec(message.headers.from); + // The previous most-recent speaker moves into the cc list; the + // new speaker becomes replyTo. Dedup so a sender returning after + // others have spoken doesn't appear twice. + const carriedCc = appendUnique(state.cc, state.replyTo).filter( + (addr) => addr !== nextSpeaker, + ); + const nextState: ConnectorThreadState = { + threadRoot: state.threadRoot, + lastMessageId: message.headers.messageId, + replyTo: nextSpeaker, + cc: carriedCc, + ...(state.subject !== undefined ? { subject: state.subject } : {}), + }; + const decision: RouteDecision = { kind: "continue" }; + pendingStates.set(decision, nextState); + return decision; + } + + return { kind: "passthrough" }; + } + + function commit(decision: RouteDecision): void { + if (decision.kind === "passthrough") return; + + const nextState = pendingStates.get(decision); + if (nextState === undefined) { + throw new Error( + "commit() called with a decision from a different router instance", + ); + } + + pendingStates.delete(decision); + applyState(nextState); + } + + function composeReply(): ConnectorReplyParts { + if (state === null) { + throw new NoActiveConnectorThreadError(); + } + + return { + to: state.replyTo, + cc: [...state.cc], + inReplyTo: state.lastMessageId, + ...(state.subject !== undefined ? { subject: state.subject } : {}), + }; + } + + function onReplySent(receipt: SendReceipt): void { + if (state === null) { + throw new NoActiveConnectorThreadError(); + } + applyState({ + threadRoot: state.threadRoot, + lastMessageId: receipt.messageId, + replyTo: state.replyTo, + cc: [...state.cc], + ...(state.subject !== undefined ? { subject: state.subject } : {}), + }); + } + + function snapshot(): ConnectorThreadState | null { + if (state === null) return null; + return { + threadRoot: state.threadRoot, + lastMessageId: state.lastMessageId, + replyTo: state.replyTo, + cc: [...state.cc], + ...(state.subject !== undefined ? { subject: state.subject } : {}), + }; + } + + function restore(next: ConnectorThreadState | null): void { + applyState( + next === null + ? null + : { + threadRoot: next.threadRoot, + lastMessageId: next.lastMessageId, + replyTo: next.replyTo, + cc: [...next.cc], + ...(next.subject !== undefined ? { subject: next.subject } : {}), + }, + ); + } + + return { + route, + commit, + composeReply, + onReplySent, + snapshot, + restore, + }; +} diff --git a/vendor/intx-harness/src/credential-capability.test.ts b/vendor/intx-harness/src/credential-capability.test.ts new file mode 100644 index 000000000..b4ea078c4 --- /dev/null +++ b/vendor/intx-harness/src/credential-capability.test.ts @@ -0,0 +1,311 @@ +import { describe, test, expect } from "bun:test"; +import { toolConsumer, type GrantRule } from "@intx/authz"; +import type { CredentialProvider, CredentialShapeContext } from "@intx/types"; + +import { createCredentialProviderRegistry } from "./credential-providers"; +import { + createCredentialCapability, + reconcileDeclaredCredentials, + type ResolvedCredentialBinding, +} from "./credential-capability"; + +function grant( + overrides: Partial & + Pick, +): GrantRule { + return { + id: "grt_test", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: null, + ...overrides, + }; +} + +// A provider that records every shape context it is handed and counts disposes, +// so the capability's threading and teardown can be asserted. +function trackingProvider(): { + provider: CredentialProvider; + shapes: CredentialShapeContext[]; + disposeCount: () => number; +} { + const shapes: CredentialShapeContext[] = []; + let disposed = 0; + const provider: CredentialProvider = { + key: "fake", + shape(ctx) { + shapes.push(ctx); + return { + kind: "http", + fetch: async () => new Response(), + dispose: () => { + disposed += 1; + }, + }; + }, + }; + return { provider, shapes, disposeCount: () => disposed }; +} + +const CONSUMER = toolConsumer("@intx/tools-example"); + +function bindingsFor(track: { provider: CredentialProvider }) { + return new Map([ + [ + "gh", + { + credentialId: "cred_gh", + providerKey: track.provider.key, + origin: "https://api.github.com", + readCurrentMaterial: () => ({ secret: "sk-1" }), + }, + ], + ]); +} + +describe("createCredentialCapability (Gate 2)", () => { + test("resolves a handle the consumer is authorized to use", async () => { + const track = trackingProvider(); + const cap = createCredentialCapability({ + consumer: CONSUMER, + bindings: bindingsFor(track), + providers: createCredentialProviderRegistry([track.provider]), + grants: [ + grant({ + resource: "credential:cred_gh", + action: "use", + effect: "allow", + conditions: { tool: CONSUMER }, + }), + ], + }); + + const handle = await cap.resolve("gh"); + expect(handle.kind).toBe("http"); + // The binding's provider was chosen and handed the right origin + material. + expect(track.shapes).toHaveLength(1); + expect(track.shapes[0]?.origin).toBe("https://api.github.com"); + expect(track.shapes[0]?.readCurrentMaterial()).toEqual({ secret: "sk-1" }); + }); + + test("a coarse credential:* / use grant (no condition) authorizes", async () => { + const track = trackingProvider(); + const cap = createCredentialCapability({ + consumer: CONSUMER, + bindings: bindingsFor(track), + providers: createCredentialProviderRegistry([track.provider]), + grants: [ + grant({ resource: "credential:*", action: "use", effect: "allow" }), + ], + }); + expect((await cap.resolve("gh")).kind).toBe("http"); + }); + + test("refuses when the grant's { tool } condition names another consumer", async () => { + const track = trackingProvider(); + const cap = createCredentialCapability({ + consumer: CONSUMER, + bindings: bindingsFor(track), + providers: createCredentialProviderRegistry([track.provider]), + grants: [ + grant({ + resource: "credential:cred_gh", + action: "use", + effect: "allow", + conditions: { tool: toolConsumer("@intx/tools-other") }, + }), + ], + }); + await expect(cap.resolve("gh")).rejects.toThrow(/not authorized/); + // The credential was never shaped on the deny path. + expect(track.shapes).toHaveLength(0); + }); + + test("refuses when no grant matches", async () => { + const track = trackingProvider(); + const cap = createCredentialCapability({ + consumer: CONSUMER, + bindings: bindingsFor(track), + providers: createCredentialProviderRegistry([track.provider]), + grants: [], + }); + await expect(cap.resolve("gh")).rejects.toThrow(/not authorized/); + }); + + test("fails closed when the consumer identity is empty", async () => { + const track = trackingProvider(); + const cap = createCredentialCapability({ + consumer: "", + bindings: bindingsFor(track), + providers: createCredentialProviderRegistry([track.provider]), + grants: [ + grant({ + resource: "credential:cred_gh", + action: "use", + effect: "allow", + conditions: { tool: CONSUMER }, + }), + ], + }); + await expect(cap.resolve("gh")).rejects.toThrow(/not authorized/); + }); + + test("throws for a handle no binding covers", async () => { + const track = trackingProvider(); + const cap = createCredentialCapability({ + consumer: CONSUMER, + bindings: bindingsFor(track), + providers: createCredentialProviderRegistry([track.provider]), + grants: [ + grant({ resource: "credential:*", action: "use", effect: "allow" }), + ], + }); + await expect(cap.resolve("nope")).rejects.toThrow( + /no credential is bound to handle "nope"/, + ); + }); + + test("memoizes: a second resolve returns the same instance, shaping once", async () => { + const track = trackingProvider(); + const cap = createCredentialCapability({ + consumer: CONSUMER, + bindings: bindingsFor(track), + providers: createCredentialProviderRegistry([track.provider]), + grants: [ + grant({ resource: "credential:*", action: "use", effect: "allow" }), + ], + }); + const a = await cap.resolve("gh"); + const b = await cap.resolve("gh"); + expect(a).toBe(b); + expect(track.shapes).toHaveLength(1); + }); + + test("dispose releases every shaped handle", async () => { + const track = trackingProvider(); + const cap = createCredentialCapability({ + consumer: CONSUMER, + bindings: bindingsFor(track), + providers: createCredentialProviderRegistry([track.provider]), + grants: [ + grant({ resource: "credential:*", action: "use", effect: "allow" }), + ], + }); + await cap.resolve("gh"); + await cap.dispose(); + expect(track.disposeCount()).toBe(1); + }); + + test("concurrent resolves of one handle shape once and share the instance", async () => { + const track = trackingProvider(); + const cap = createCredentialCapability({ + consumer: CONSUMER, + bindings: bindingsFor(track), + providers: createCredentialProviderRegistry([track.provider]), + grants: [ + grant({ resource: "credential:*", action: "use", effect: "allow" }), + ], + }); + const [a, b] = await Promise.all([cap.resolve("gh"), cap.resolve("gh")]); + expect(a).toBe(b); + expect(track.shapes).toHaveLength(1); + }); + + test("dispose isolates a throwing handle, releasing the rest and surfacing the error", async () => { + let goodDisposed = 0; + const badProvider: CredentialProvider = { + key: "bad", + shape: () => ({ + kind: "http", + fetch: async () => new Response(), + dispose: () => { + throw new Error("boom"); + }, + }), + }; + const goodProvider: CredentialProvider = { + key: "good", + shape: () => ({ + kind: "http", + fetch: async () => new Response(), + dispose: () => { + goodDisposed += 1; + }, + }), + }; + const bindings = new Map([ + [ + "bad", + { + credentialId: "cred_bad", + providerKey: "bad", + origin: "https://a.example.com", + readCurrentMaterial: () => ({ secret: "s" }), + }, + ], + [ + "good", + { + credentialId: "cred_good", + providerKey: "good", + origin: "https://b.example.com", + readCurrentMaterial: () => ({ secret: "s" }), + }, + ], + ]); + const cap = createCredentialCapability({ + consumer: CONSUMER, + bindings, + providers: createCredentialProviderRegistry([badProvider, goodProvider]), + grants: [ + grant({ resource: "credential:*", action: "use", effect: "allow" }), + ], + }); + await cap.resolve("bad"); + await cap.resolve("good"); + + await expect(cap.dispose()).rejects.toThrow(/failed to dispose/); + // The healthy handle was still released despite the bad one throwing. + expect(goodDisposed).toBe(1); + }); +}); + +describe("reconcileDeclaredCredentials", () => { + test("passes when every declared handle has a binding", () => { + expect(() => + reconcileDeclaredCredentials( + CONSUMER, + [{ handle: "gh" }, { handle: "stripe" }], + new Set(["gh", "stripe"]), + ), + ).not.toThrow(); + }); + + test("passes with no declarations (a tool that declares nothing)", () => { + expect(() => + reconcileDeclaredCredentials(CONSUMER, [], new Set()), + ).not.toThrow(); + }); + + test("ignores extra bindings the tool did not declare", () => { + expect(() => + reconcileDeclaredCredentials( + CONSUMER, + [{ handle: "gh" }], + new Set(["gh", "unused"]), + ), + ).not.toThrow(); + }); + + test("fails the launch, naming each declared handle with no binding", () => { + expect(() => + reconcileDeclaredCredentials( + CONSUMER, + [{ handle: "gh" }, { handle: "stripe" }], + new Set(["gh"]), + ), + ).toThrow(/no binding resolves: stripe/); + }); +}); diff --git a/vendor/intx-harness/src/credential-capability.ts b/vendor/intx-harness/src/credential-capability.ts new file mode 100644 index 000000000..4705a16b5 --- /dev/null +++ b/vendor/intx-harness/src/credential-capability.ts @@ -0,0 +1,178 @@ +// The consumer-gated `credentials` capability: the sub-registry a tool queries +// by its declared handle to obtain a mediated credential. It is the runtime +// gate that enforces the `{ tool }` condition on a materialized +// `credential:{id}` / `use` grant -- the check the launch-time grant +// materialization sets up but does not itself evaluate. +// +// The gate lives here, at the point of use, and fails closed: a handle resolves +// only when the calling consumer holds `credential:{id}` / `use` with the +// grant's `{ tool }` condition matching this consumer. The shaping of the +// handle is delegated to the provider registry; the material is read fresh per +// use (rotation indirection) from the source the binding carries. + +import { + authorizeAction, + CREDENTIAL_USE_CONDITIONS, + type GrantRule, +} from "@intx/authz"; +import type { + CredentialCapability, + CredentialMaterialSource, + MediatedCredential, +} from "@intx/types"; +import type { ToolCredentialDeclaration } from "@intx/types/package-json"; + +import type { CredentialProviderRegistry } from "./credential-providers"; + +/** + * A binding resolved at launch: which credential backs a declared handle, which + * provider shapes it, the origin it authenticates to, and how to read its + * current material. The material source is an indirection over a mutable cell so + * a rotation reaches an already-shaped handle without a rebuild. + */ +export interface ResolvedCredentialBinding { + /** The credential row id the handle resolved to; the `credential:{id}` the + * use-grant check runs against. */ + credentialId: string; + /** The provider plugin key that shapes this credential's handle. */ + providerKey: string; + /** The provider origin the shaped handle authenticates to. */ + origin: string; + /** Reads the current secret material (rotation indirection). */ + readCurrentMaterial: CredentialMaterialSource; +} + +/** + * Reconcile a tool package's declared credential handles (its C5 `interchange. + * credentials`) against the handles a binding actually resolved for it. A + * declared handle with no binding is a launch-blocking misconfiguration -- the + * tool needs a credential the definition never bound -- so this fails the launch + * loudly rather than letting the gap surface as a resolve-time throw at the + * tool's first use. It is the throw-on-missing of `resolve`, pulled earlier to + * launch where the whole set is known. + */ +export function reconcileDeclaredCredentials( + consumer: string, + declared: readonly ToolCredentialDeclaration[], + boundHandles: ReadonlySet, +): void { + const missing = declared + .map((declaration) => declaration.handle) + .filter((handle) => !boundHandles.has(handle)); + if (missing.length > 0) { + throw new Error( + `consumer ${consumer} declares credential handle(s) that no binding resolves: ${missing.join(", ")}`, + ); + } +} + +export interface CredentialCapabilityDeps { + /** + * The consumer identity of the tool package this capability serves + * (`tool:`, from `toolConsumer`). Gate 2 checks each grant's + * `{ tool }` condition against this value; an empty identity fails closed. + */ + consumer: string; + /** Resolved bindings keyed by the handle the tool declared. */ + bindings: ReadonlyMap; + /** The registry that shapes a credential into a mediated handle. */ + providers: CredentialProviderRegistry; + /** The grants in effect for this deploy (the consumer's run grants). */ + grants: GrantRule[]; +} + +/** + * A `CredentialCapability` plus a host-only `dispose`. The tool sees only + * `resolve`; the host runs `dispose` on teardown to release every handle shaped + * through this capability (an http handle holds nothing; a future key-file / + * socket handle would). + */ +export interface HostCredentialCapability extends CredentialCapability { + dispose(): Promise; +} + +/** + * Build the consumer-gated `credentials` capability for one tool package. + * + * `resolve(handle)` fails closed at every step: an unbound handle throws; a + * handle the consumer is not authorized to use throws (Gate 2 -- the same + * `authorizeAction` the model-source path uses, here supplied the credential-use + * condition registry and this consumer). Only an authorized handle is shaped, + * once, and memoized so repeated resolves return the same instance and there is + * a single thing to dispose. + */ +export function createCredentialCapability( + deps: CredentialCapabilityDeps, +): HostCredentialCapability { + // Memoize the in-flight PROMISE, not the resolved handle, so two concurrent + // resolves of the same handle share one gate+shape and yield one instance + // (caching the value would let both miss the memo and shape twice, orphaning + // a handle). A deterministic failure -- unbound handle, denied gate, unknown + // provider -- caches too; it stays failed for this deploy, which is correct + // since grants do not change mid-deploy. + const shaped = new Map>(); + + function shapeHandle(handle: string): Promise { + return (async () => { + const binding = deps.bindings.get(handle); + if (binding === undefined) { + throw new Error( + `no credential is bound to handle "${handle}" for consumer ${deps.consumer}`, + ); + } + + // Gate 2: fail closed unless the consumer holds credential:{id} / use with + // the grant's { tool } condition matching this consumer. + const decision = await authorizeAction( + deps.grants, + `credential:${binding.credentialId}`, + "use", + { registry: CREDENTIAL_USE_CONDITIONS, consumer: deps.consumer }, + ); + if (!decision.ok) { + throw new Error( + `consumer ${deps.consumer} is not authorized to use credential ${binding.credentialId} (${decision.reason})`, + ); + } + + const provider = deps.providers.resolve(binding.providerKey); + return provider.shape({ + origin: binding.origin, + readCurrentMaterial: binding.readCurrentMaterial, + }); + })(); + } + + return { + resolve(handle: string): Promise { + const existing = shaped.get(handle); + if (existing !== undefined) return existing; + const pending = shapeHandle(handle); + shaped.set(handle, pending); + return pending; + }, + + async dispose(): Promise { + // Dispose EVERY successfully-shaped handle even if one throws -- a single + // bad handle must not strand the rest -- then surface any failures loudly + // rather than swallowing them. + const settled = await Promise.allSettled([...shaped.values()]); + shaped.clear(); + const errors: unknown[] = []; + for (const result of settled) { + if (result.status !== "fulfilled") continue; + try { + await result.value.dispose(); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new AggregateError( + errors, + "one or more credential handles failed to dispose", + ); + } + }, + }; +} diff --git a/vendor/intx-harness/src/credential-providers.test.ts b/vendor/intx-harness/src/credential-providers.test.ts new file mode 100644 index 000000000..88b2f3e90 --- /dev/null +++ b/vendor/intx-harness/src/credential-providers.test.ts @@ -0,0 +1,294 @@ +import { describe, test, expect } from "bun:test"; +import type { CredentialProvider } from "@intx/types"; + +import { + createCredentialProviderRegistry, + createHttpCredentialProvider, + builtinCredentialProviders, + type FetchLike, +} from "./credential-providers"; + +// A fetch stub that records the URL and Authorization header it was handed, +// so origin-pinning and auth injection can be checked without a network. +function recordingFetch(): { + fetch: FetchLike; + last: () => { url: string; auth: string | null } | undefined; +} { + let captured: { url: string; auth: string | null } | undefined; + const fetch: FetchLike = async (input, init) => { + const req = + input instanceof Request ? input : new Request(String(input), init); + captured = { url: req.url, auth: req.headers.get("authorization") }; + return new Response("ok", { status: 200 }); + }; + return { fetch, last: () => captured }; +} + +function makeHandle(net: { fetch: FetchLike }, secret = "sk-1") { + const provider = createHttpCredentialProvider({ fetch: net.fetch }); + return provider.shape({ + origin: "https://api.example.com", + readCurrentMaterial: () => ({ secret }), + }); +} + +describe("createHttpCredentialProvider", () => { + test("injects the current secret as a bearer token, pinned to the origin", async () => { + const net = recordingFetch(); + const provider = createHttpCredentialProvider({ fetch: net.fetch }); + const handle = provider.shape({ + origin: "https://api.example.com", + readCurrentMaterial: () => ({ secret: "sk-1" }), + }); + + const res = await handle.fetch("/repos"); + expect(res.status).toBe(200); + expect(net.last()).toEqual({ + url: "https://api.example.com/repos", + auth: "Bearer sk-1", + }); + }); + + test("reads material fresh on every call so a rotation is picked up", async () => { + const net = recordingFetch(); + let secret = "sk-old"; + const provider = createHttpCredentialProvider({ fetch: net.fetch }); + const handle = provider.shape({ + origin: "https://api.example.com", + readCurrentMaterial: () => ({ secret }), + }); + + await handle.fetch("/a"); + expect(net.last()?.auth).toBe("Bearer sk-old"); + + // Rotate the underlying cell; the same handle must use the new secret. + secret = "sk-new"; + await handle.fetch("/b"); + expect(net.last()?.auth).toBe("Bearer sk-new"); + }); + + test("refuses a cross-origin request and never reaches fetch", async () => { + const net = recordingFetch(); + const provider = createHttpCredentialProvider({ fetch: net.fetch }); + const handle = provider.shape({ + origin: "https://api.example.com", + readCurrentMaterial: () => ({ secret: "sk-1" }), + }); + + await expect( + handle.fetch("https://evil.example.net/steal"), + ).rejects.toThrow( + /pinned to https:\/\/api\.example\.com; refusing cross-origin/, + ); + // The stub was never called, so the token never left for the other origin. + expect(net.last()).toBeUndefined(); + }); + + test("preserves method and body when handed a Request, adding auth", async () => { + const net = recordingFetch(); + const provider = createHttpCredentialProvider({ fetch: net.fetch }); + const handle = provider.shape({ + origin: "https://api.example.com", + readCurrentMaterial: () => ({ secret: "sk-1" }), + }); + + await handle.fetch( + new Request("https://api.example.com/issues", { method: "POST" }), + ); + expect(net.last()).toEqual({ + url: "https://api.example.com/issues", + auth: "Bearer sk-1", + }); + }); + + test("a caller-supplied Authorization header is overridden by the credential's", async () => { + const net = recordingFetch(); + const provider = createHttpCredentialProvider({ fetch: net.fetch }); + const handle = provider.shape({ + origin: "https://api.example.com", + readCurrentMaterial: () => ({ secret: "sk-real" }), + }); + + await handle.fetch("/x", { + headers: { authorization: "Bearer sk-attacker" }, + }); + expect(net.last()?.auth).toBe("Bearer sk-real"); + }); + + test("dispose is callable on the http handle", () => { + const provider = createHttpCredentialProvider(); + const handle = provider.shape({ + origin: "https://api.example.com", + readCurrentMaterial: () => ({ secret: "sk-1" }), + }); + expect(() => handle.dispose()).not.toThrow(); + }); +}); + +describe("createCredentialProviderRegistry", () => { + const fake: CredentialProvider = { + key: "fake", + shape: () => ({ + kind: "http", + fetch: async () => new Response(), + dispose: () => { + /* the fake holds no resources */ + }, + }), + }; + + test("resolves each registered provider by key", () => { + const registry = createCredentialProviderRegistry([ + createHttpCredentialProvider(), + fake, + ]); + expect(registry.resolve("http").key).toBe("http"); + expect(registry.resolve("fake")).toBe(fake); + expect(registry.has("fake")).toBe(true); + expect(registry.has("nope")).toBe(false); + }); + + test("throws loudly on an unknown provider", () => { + const registry = createCredentialProviderRegistry( + builtinCredentialProviders(), + ); + expect(() => registry.resolve("nope")).toThrow( + /Unknown credential provider: nope/, + ); + }); + + test("an untrusted key cannot reach an Object.prototype member", () => { + const registry = createCredentialProviderRegistry( + builtinCredentialProviders(), + ); + // "toString" exists on Object.prototype; a Map-backed lookup must not find + // it and try to invoke it as a provider. + expect(() => registry.resolve("toString")).toThrow( + /Unknown credential provider: toString/, + ); + expect(registry.has("toString")).toBe(false); + }); + + test("a duplicate provider key is a wiring error at construction", () => { + expect(() => createCredentialProviderRegistry([fake, fake])).toThrow( + /Duplicate credential provider key: fake/, + ); + }); +}); + +describe("http credential origin-pinning (adversarial)", () => { + // Origin pinning is the load-bearing security property: in every refusal + // case the request must be rejected AND fetch must never be reached, so the + // bearer token never leaves for the other origin. + test("a protocol-relative //host is refused", async () => { + const net = recordingFetch(); + await expect( + makeHandle(net).fetch("//evil.example.net/steal"), + ).rejects.toThrow(/refusing cross-origin/); + expect(net.last()).toBeUndefined(); + }); + + test("a suffix-confusion host (api.example.com.evil.com) is refused", async () => { + const net = recordingFetch(); + await expect( + makeHandle(net).fetch("https://api.example.com.evil.com/x"), + ).rejects.toThrow(/refusing cross-origin/); + expect(net.last()).toBeUndefined(); + }); + + test("a userinfo trick (api.example.com@evil.com) is refused", async () => { + const net = recordingFetch(); + await expect( + makeHandle(net).fetch("https://api.example.com@evil.com/x"), + ).rejects.toThrow(/refusing cross-origin/); + expect(net.last()).toBeUndefined(); + }); + + test("a non-default port on the pinned host is a distinct origin, refused", async () => { + const net = recordingFetch(); + await expect( + makeHandle(net).fetch("https://api.example.com:8443/x"), + ).rejects.toThrow(/refusing cross-origin/); + expect(net.last()).toBeUndefined(); + }); + + test("a scheme downgrade (http to a pinned https origin) is refused", async () => { + const net = recordingFetch(); + await expect( + makeHandle(net).fetch("http://api.example.com/x"), + ).rejects.toThrow(/refusing cross-origin/); + expect(net.last()).toBeUndefined(); + }); + + test("a cross-origin URL object is refused", async () => { + const net = recordingFetch(); + await expect( + makeHandle(net).fetch(new URL("https://evil.example.net/x")), + ).rejects.toThrow(/refusing cross-origin/); + expect(net.last()).toBeUndefined(); + }); + + test("a cross-origin Request object is refused (its url is the checked one)", async () => { + const net = recordingFetch(); + await expect( + makeHandle(net).fetch(new Request("https://evil.example.net/x")), + ).rejects.toThrow(/refusing cross-origin/); + expect(net.last()).toBeUndefined(); + }); + + test("the Request branch overrides a mixed-case caller Authorization header", async () => { + const net = recordingFetch(); + await makeHandle(net, "sk-real").fetch( + new Request("https://api.example.com/x", { + headers: { Authorization: "Bearer sk-attacker" }, + }), + ); + expect(net.last()?.auth).toBe("Bearer sk-real"); + }); +}); + +describe("http credential redirect handling", () => { + // The handle never follows a redirect: a 3xx is returned to the caller + // unfollowed, so the bearer is never sent to the redirect target's origin. + test("returns a 3xx to the caller and calls fetch exactly once", async () => { + let calls = 0; + const fetch: FetchLike = async () => { + calls += 1; + return new Response(null, { + status: 302, + headers: { location: "https://evil.example.net/" }, + }); + }; + const handle = createHttpCredentialProvider({ fetch }).shape({ + origin: "https://api.example.com", + readCurrentMaterial: () => ({ secret: "sk-1" }), + }); + + const res = await handle.fetch("/x"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("https://evil.example.net/"); + // Never chased the Location -- the token never left the pinned origin. + expect(calls).toBe(1); + }); + + test("forces redirect:manual even when a caller Request asks to follow", async () => { + const seen: (string | undefined)[] = []; + const fetch: FetchLike = async (input, init) => { + seen.push(input instanceof Request ? input.redirect : init?.redirect); + return new Response("ok"); + }; + const handle = createHttpCredentialProvider({ fetch }).shape({ + origin: "https://api.example.com", + readCurrentMaterial: () => ({ secret: "sk-1" }), + }); + + // Non-Request branch: the init the handle passes forces manual. + await handle.fetch("/a"); + // Request branch: the caller's redirect:"follow" must be overridden. + await handle.fetch( + new Request("https://api.example.com/b", { redirect: "follow" }), + ); + + expect(seen).toEqual(["manual", "manual"]); + }); +}); diff --git a/vendor/intx-harness/src/credential-providers.ts b/vendor/intx-harness/src/credential-providers.ts new file mode 100644 index 000000000..f8ece1cea --- /dev/null +++ b/vendor/intx-harness/src/credential-providers.ts @@ -0,0 +1,179 @@ +// Credential provider plugins: the seam that shapes a resolved provider-backed +// credential into a mediated handle a consumer can use. A provider owns HOW the +// handle authenticates (an authed `fetch`, a future key-file + socket); it is +// given a material source and never acquires material or decides authorization +// -- both happen upstream, at the delivery boundary, before a provider is +// consulted. +// +// The registry mirrors @intx/inference's AdapterRegistry: a Map-backed lookup +// keyed by provider identifier, prototype-pollution-safe (a Map never consults +// Object.prototype, so an untrusted key like "toString" resolves to the loud +// unknown-provider error rather than an inherited member), throw-on-missing. + +import type { + CredentialProvider, + CredentialShapeContext, + HttpMediatedCredential, +} from "@intx/types"; + +/** Resolves a provider identifier to the plugin that shapes its handles. */ +export interface CredentialProviderRegistry { + has(key: string): boolean; + resolve(key: string): CredentialProvider; +} + +/** + * Build a registry from a list of providers. The list is copied into a private + * `Map`, so callers cannot mutate the set after construction and lookups never + * reach `Object.prototype`. A duplicate key is a wiring error and throws at + * construction rather than silently shadowing. + */ +export function createCredentialProviderRegistry( + providers: readonly CredentialProvider[], +): CredentialProviderRegistry { + const byKey = new Map(); + for (const provider of providers) { + if (byKey.has(provider.key)) { + throw new Error(`Duplicate credential provider key: ${provider.key}`); + } + byKey.set(provider.key, provider); + } + + return { + has(key: string): boolean { + return byKey.has(key); + }, + resolve(key: string): CredentialProvider { + const provider = byKey.get(key); + if (provider === undefined) { + throw new Error(`Unknown credential provider: ${key}`); + } + return provider; + }, + }; +} + +/** + * The minimal call signature the shaped handle needs from `fetch`. The global + * `fetch` satisfies it; a test stub can too, without implementing the extra + * members (`preconnect`) the full `fetch` type carries. + */ +export type FetchLike = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +/** Options for the built-in HTTP provider. */ +export interface HttpCredentialProviderOptions { + /** + * The `fetch` the shaped handle delegates to once the request is + * origin-checked and the auth header is injected. Defaults to the global + * `fetch`; injectable so origin-pinning can be exercised without a network. + */ + fetch?: FetchLike; +} + +/** + * The built-in HTTP credential provider. It shapes an `HttpMediatedCredential`: + * an authed `fetch` pinned to the credential's provider origin, injecting the + * current secret as a bearer token per request. The material is read fresh on + * every call, so a rotation that updates the underlying cell is picked up + * without rebuilding the handle. + * + * Origin pinning is load-bearing security: the handle authenticates only the + * initial, origin-checked request and never follows redirects. A request whose + * resolved origin is not the pinned one is refused, and a server 3xx is + * returned to the caller unfollowed (`redirect: "manual"`), so the bearer is + * never sent to any origin but the pinned one. Transparent redirect-following + * is intentionally not provided: a tool re-issues a same-origin redirect target + * through the handle (a cross-origin one is refused). This keeps token safety + * in the handle rather than resting on the injected `fetch`'s redirect + * behavior. + * + * Bearer is the only auth scheme today; providers that authenticate differently + * (a `token` scheme, an `x-api-key` header) are separate plugins, not a branch + * here. + */ +export function createHttpCredentialProvider( + opts?: HttpCredentialProviderOptions, +): CredentialProvider { + const fetchImpl: FetchLike = opts?.fetch ?? globalThis.fetch; + + return { + key: "http", + shape(context: CredentialShapeContext): HttpMediatedCredential { + const pinnedOrigin = new URL(context.origin).origin; + + return { + kind: "http", + async fetch( + input: string | URL | Request, + init?: RequestInit, + ): Promise { + const target = resolveTargetUrl(input, pinnedOrigin); + if (target.origin !== pinnedOrigin) { + throw new Error( + `http credential is pinned to ${pinnedOrigin}; refusing cross-origin request to ${target.origin}`, + ); + } + + // Read the secret fresh on every call so a rotation of the underlying + // material cell reaches this handle without a rebuild. + const { secret } = context.readCurrentMaterial(); + + // redirect:"manual" is dictated by the handle, never inherited from + // caller input. The origin check guards only the INITIAL url, so + // following a server 3xx to a foreign origin would carry the bearer + // off the pinned host. Instead the 3xx is returned to the caller + // unfollowed: a same-origin target is re-issued through the handle + // (which re-pins and re-auths); a cross-origin one is refused above. + if (input instanceof Request) { + // Re-issue the caller's request (method, body preserved) with the + // auth header added and the redirect mode forced; its url was + // origin-checked above. + const headers = new Headers(input.headers); + headers.set("authorization", `Bearer ${secret}`); + return fetchImpl( + new Request(input, { headers, redirect: "manual" }), + ); + } + + const headers = new Headers(init?.headers); + headers.set("authorization", `Bearer ${secret}`); + return fetchImpl(target, { ...init, headers, redirect: "manual" }); + }, + dispose(): void { + // An http handle allocates no resources; nothing to release. + }, + }; + }, + }; +} + +/** + * The built-in credential providers every host registers. A single `http` + * provider today; a host composes additional providers by extending the list + * passed to `createCredentialProviderRegistry`. + */ +export function builtinCredentialProviders(): CredentialProvider[] { + return [createHttpCredentialProvider()]; +} + +/** + * Resolve the URL a request targets. A relative string resolves against the + * pinned origin (so a tool can call `/repos`); an absolute string or URL keeps + * its own origin (and is refused by the caller if it differs); a `Request` + * carries an absolute URL already. + */ +function resolveTargetUrl( + input: string | URL | Request, + pinnedOrigin: string, +): URL { + if (typeof input === "string") { + return new URL(input, pinnedOrigin); + } + if (input instanceof URL) { + return input; + } + return new URL(input.url); +} diff --git a/vendor/intx-harness/src/harness.test.ts b/vendor/intx-harness/src/harness.test.ts new file mode 100644 index 000000000..92b068a32 --- /dev/null +++ b/vendor/intx-harness/src/harness.test.ts @@ -0,0 +1,873 @@ +// Composition-layer tests for `createHarness`. +// +// These tests verify the layer's own responsibilities -- INBOX watch +// subscription, the connector router's pass-through default, lifecycle +// teardown, and the pass-through surface exposed to consumers. +// Behaviours that moved into `@intx/agent` as part of the harness +// split (audit accumulation and flush, reactor lifecycle, source +// rotation, env-validation field-by-field blame) are exercised by the +// agent package's own tests. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + AgentContextLockError, + createDefaultDirectorRegistry, + createDirectorRegistry, + defaultDirectorFactory, + defineAgent, + defineDirector, +} from "@intx/agent"; +import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; +import { type } from "arktype"; +import { + setupHarness as setupInferenceHarness, + type Harness as InferenceTestHarness, +} from "@intx/inference-testing"; +import { createInboundMessage } from "@intx/mime"; +import { createIsogitStore } from "@intx/storage-isogit/node"; +import type { + ContextStore, + InboundMessage, + InferenceSource, + MessageRef, + MessageTransport, + ReactorCapabilities, + ReactorInboundEvent, + ReactorState, +} from "@intx/types/runtime"; + +import { createConnectorRouter } from "./connector-router"; +import { + createHarness, + createWrappedStorageOverrides, + defineMailTools, + type MailEnv, +} from "./harness"; + +const SOURCE: InferenceSource = { + id: "anthropic:claude-3-5-sonnet", + provider: "anthropic", + baseURL: "https://api.anthropic.com", + apiKey: "sk-test-harness", + model: "claude-3-5-sonnet", +}; + +const AGENT_ADDRESS = "agent@test.local"; + +interface MockTransportShape { + fireExists(uid: number): void; + enqueue(uid: number, message: InboundMessage): void; + watchCount(): number; + unsubscribeCount(): number; + getDeletedRefs(): MessageRef[]; + getFetchedUids(): number[]; + getSent(): unknown[]; +} + +function makeInboundMessage(uid: number): InboundMessage { + // The harness's INBOX pipeline reads `ref.uid`, `ref.mailbox`, and + // (via the connector router) headers like `from`, `to`, + // `inReplyTo`, `references`. Anything else stays mock-shaped. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub, never inspected beyond the fields the harness pipeline reads + return { + ref: { uid, mailbox: "INBOX" }, + headers: { + from: "alice@example.com", + to: AGENT_ADDRESS, + subject: "subject", + date: new Date().toISOString(), + messageId: `<${String(uid)}@test>`, + inReplyTo: undefined, + references: [], + }, + } as unknown as InboundMessage; +} + +function makeMockTransport(): { + transport: MessageTransport; + control: MockTransportShape; +} { + type WatchCallback = (event: { + type: string; + uid: number; + headers?: unknown; + }) => void; + const callbacks: WatchCallback[] = []; + const deletedRefs: MessageRef[] = []; + const fetchedUids: number[] = []; + const sent: unknown[] = []; + const messages = new Map(); + let unsubscribes = 0; + + // The harness reads `transport.watch`, `transport.fetchFull`, + // `transport.setFlags`, `transport.expunge`, and `transport.send`. + // The mock provides those; the rest of the `MessageTransport` + // surface is satisfied via the double-cast pattern, which the + // project conventions sanction for library-type test stubs. + const stub = { + watch(_mailbox: unknown, callback: WatchCallback): () => void { + callbacks.push(callback); + return () => { + unsubscribes += 1; + }; + }, + async fetchFull(ref: MessageRef): Promise { + fetchedUids.push(ref.uid); + const message = messages.get(ref.uid); + if (message === undefined) { + throw new Error(`no message for uid ${String(ref.uid)}`); + } + return message; + }, + async setFlags(ref: MessageRef): Promise { + deletedRefs.push(ref); + }, + async expunge(): Promise<{ expungedUids: number[] }> { + // No-op for the mock; the test asserts via deletedRefs. + return { expungedUids: [] }; + }, + async send(message: unknown): Promise<{ messageId: string }> { + sent.push(message); + return { messageId: `` }; + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- partial mock of a large library interface (MESSAGE.md); methods the harness does not call are not implemented + const transport = stub as unknown as MessageTransport; + + return { + transport, + control: { + fireExists(uid: number) { + for (const cb of callbacks) { + cb({ type: "exists", uid }); + } + }, + enqueue(uid: number, message: InboundMessage) { + messages.set(uid, message); + }, + watchCount(): number { + return callbacks.length; + }, + unsubscribeCount(): number { + return unsubscribes; + }, + getDeletedRefs(): MessageRef[] { + return deletedRefs; + }, + getFetchedUids(): number[] { + return fetchedUids; + }, + getSent(): unknown[] { + return sent; + }, + }, + }; +} + +function mailEnv(opts: { + workdir: string; + storage: ContextStore; + transport: MessageTransport; +}): MailEnv { + return { + sources: [SOURCE], + defaultSource: SOURCE.id, + storage: opts.storage, + workdir: opts.workdir, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDefaultDirectorRegistry(), + transport: opts.transport, + address: AGENT_ADDRESS, + }; +} + +// Empty mail-tool factory: declares the env requirements without +// providing actual mail tools. These tests do not exercise mail-tool +// invocation; they only verify the composition layer's transport-side +// pipeline. +const emptyMailFactory = defineMailTools( + () => ({ + definitions: [], + async run(call) { + return { callId: call.id, content: "" }; + }, + }), + [], +); + +function emptyDef() { + return defineAgent({ + id: "harness-test", + systemPrompt: "test", + tools: [emptyMailFactory], + capabilities: [], + inference: { + sources: [{ provider: SOURCE.provider, model: SOURCE.model }], + }, + }); +} + +describe("createHarness", () => { + let workDir: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "harness-test-")); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + test("subscribes to INBOX on construction", async () => { + const { transport, control } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + const harness = await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport }), + ); + try { + expect(control.watchCount()).toBeGreaterThanOrEqual(1); + } finally { + await harness.close(); + } + }); + + test("close unsubscribes the INBOX watch", async () => { + const { transport, control } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + const harness = await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport }), + ); + expect(control.unsubscribeCount()).toBe(0); + await harness.close(); + expect(control.unsubscribeCount()).toBeGreaterThanOrEqual(1); + }); + + test("close is idempotent", async () => { + const { transport } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + const harness = await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport }), + ); + await harness.close(); + await harness.close(); + }); + + test("watch 'exists' event causes the harness to fetch the message", async () => { + const { transport, control } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + const harness = await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport }), + ); + + try { + const message = makeInboundMessage(42); + control.enqueue(42, message); + control.fireExists(42); + + // Yield so the async watch callback resolves its fetch. + await new Promise((resolve) => setTimeout(resolve, 50)); + + // The harness must have fetched the message via the transport. + // Whether it then consumes from INBOX (start/continue routing) + // or leaves it intact (passthrough) depends on the inbound + // headers; both outcomes mean the pipeline ran, but the fetch + // is the precondition. + expect(control.getFetchedUids()).toContain(42); + } finally { + await harness.close(); + } + }); + + test("exposes a stream() pass-through to the underlying agent", async () => { + const { transport } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + const harness = await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport }), + ); + try { + const iter = harness.stream(); + expect(iter).toBeDefined(); + expect(typeof iter[Symbol.asyncIterator]).toBe("function"); + } finally { + await harness.close(); + } + }); + + test("exposes blobReader from the underlying agent", async () => { + const { transport } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + const harness = await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport }), + ); + try { + expect(harness.blobReader).toBeDefined(); + expect(typeof harness.blobReader.read).toBe("function"); + } finally { + await harness.close(); + } + }); +}); + +describe("createHarness outbound pipeline", () => { + let workDir: string; + let inference: InferenceTestHarness; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "harness-outbound-")); + inference = setupInferenceHarness(); + }); + + afterEach(() => { + inference.dispose(); + rmSync(workDir, { recursive: true, force: true }); + }); + + // Exercises the end-to-end outbound path: an INBOX-routed start + // decision drives the agent through stubbed inference, the agent + // emits connector.reply, and the harness's drain forwards the + // reply via transport.send. + test("delivers a connector.reply through to transport.send", async () => { + inference.scenario.replyOnce("anthropic", { text: "outbound reply" }); + + const { transport, control } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + + const env: MailEnv = { + ...mailEnv({ workdir: workDir, storage, transport }), + sources: [ + { + id: "anthropic:claude-3-5-sonnet", + provider: "anthropic", + baseURL: "https://api.anthropic.com", + apiKey: "sk-test-harness-outbound", + model: "claude-3-5-sonnet", + }, + ], + defaultSource: "anthropic:claude-3-5-sonnet", + deps: inference.deps, + }; + + const harness = await createHarness(emptyDef(), env); + + try { + const message = createInboundMessage({ + from: "alice@example.com", + to: AGENT_ADDRESS, + content: "Hello agent", + interchangeType: "conversation.message", + }); + const stored: InboundMessage = { + ...message, + ref: { uid: 101, mailbox: "INBOX" }, + }; + control.enqueue(101, stored); + control.fireExists(101); + + // Poll for the outbound send rather than relying on fixed-time + // sleeps; the reply drain runs on microtasks, so the assertion + // meets within a few iterations. + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + await inference.run(); + if (control.getSent().length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + expect(control.getSent().length).toBeGreaterThanOrEqual(1); + } finally { + await harness.close(); + } + }); +}); + +describe("createWrappedStorageOverrides dirty-bit gating", () => { + // The boot-only restore fix pins the following invariant: the + // wrapped storage's `load()` calls `connectorRouter.restore(...)` + // only while no router commit has produced a state change. Once the + // router emits a state change the harness flips its in-memory-state- + // authoritative bit, and every subsequent `load()` returns the + // delegate's payload unchanged without resetting the router's + // in-memory snapshot. These tests pin the bit-gating directly so a + // regression in the load() guard is caught without depending on the + // full reactor cycle the end-to-end test exercises. + + const makeStubStorage = ( + connectorState: unknown, + ): { + storage: ContextStore; + loadCount: () => number; + setConnectorStateCalls: () => unknown[]; + } => { + let loads = 0; + const setCalls: unknown[] = []; + const stub = { + async load() { + loads += 1; + return { + history: [], + pendingOperations: [], + tokenUsage: { totalInputTokens: 0, totalOutputTokens: 0 }, + connectorState, + }; + }, + setConnectorState(state: unknown) { + setCalls.push(state); + }, + async writeMetadata() { + // No-op: the test does not exercise the persisted-write path. + }, + }; + return { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- partial mock; methods the override does not call are not implemented + storage: stub as unknown as ContextStore, + loadCount: () => loads, + setConnectorStateCalls: () => setCalls, + }; + }; + + const stateFromDisk = { + threadRoot: "", + lastMessageId: "", + replyTo: "alice@example.com", + cc: [], + }; + + const stateFromRouter = { + threadRoot: "", + lastMessageId: "", + replyTo: "alice@example.com", + cc: [], + }; + + test("restores from disk while the bit is unset", async () => { + const router = createConnectorRouter(); + const { storage } = makeStubStorage(stateFromDisk); + const overrides = createWrappedStorageOverrides( + storage, + router, + () => false, + ); + expect(router.snapshot()).toBeNull(); + await overrides.load(); + // Bit unset -> restore() was called, router now reflects disk. + expect(router.snapshot()).toEqual(stateFromDisk); + }); + + test("does not restore from disk after the bit is set", async () => { + const router = createConnectorRouter(); + router.restore(stateFromRouter); + expect(router.snapshot()).toEqual(stateFromRouter); + + // Bit set: the in-memory router state is authoritative. The + // override must NOT call restore() with the disk's stale payload. + const bit = true; + const { storage } = makeStubStorage(stateFromDisk); + const overrides = createWrappedStorageOverrides(storage, router, () => bit); + await overrides.load(); + expect(router.snapshot()).toEqual(stateFromRouter); + }); + + test("respects the bit's live value across successive loads", async () => { + // The harness reads the bit as a thunk every load, so a flip + // between two loads must be observed. The first load restores + // (bit=false). After the flip, the second load preserves the + // router's then-current snapshot (bit=true). Asserts both halves + // of the gating in a single closure so a single-direction read of + // the bit (cached at construction time) would fail one of them. + const router = createConnectorRouter(); + let bit = false; + const { storage, loadCount } = makeStubStorage(stateFromDisk); + const overrides = createWrappedStorageOverrides(storage, router, () => bit); + + // First load: bit unset, restore from disk. + await overrides.load(); + expect(router.snapshot()).toEqual(stateFromDisk); + expect(loadCount()).toBe(1); + + // Simulate a router commit setting in-memory state and flipping + // the bit (this is the wiring the harness installs via + // onStateChanged). + router.restore(stateFromRouter); + bit = true; + + // Second load: bit set, must NOT restore from disk. + await overrides.load(); + expect(router.snapshot()).toEqual(stateFromRouter); + expect(loadCount()).toBe(2); + }); + + test("writeMetadata flushes the router's current snapshot through setConnectorState", async () => { + // Independent of the gating, the writeMetadata override has to + // forward the router's snapshot into the delegate store's + // setConnectorState buffer so the next durable write picks it up. + const router = createConnectorRouter(); + router.restore(stateFromRouter); + const { storage, setConnectorStateCalls } = makeStubStorage(null); + const overrides = createWrappedStorageOverrides( + storage, + router, + () => true, + ); + await overrides.writeMetadata({ + pendingOperations: [], + tokenUsage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }, + }); + expect(setConnectorStateCalls()).toEqual([stateFromRouter]); + }); +}); + +describe("defineMailTools", () => { + test("produces a factory declaring transport and address requirements", () => { + const factory = defineMailTools( + () => ({ + definitions: [], + async run(call) { + return { callId: call.id, content: "" }; + }, + }), + [], + ); + expect(factory.id).toBe("@intx/harness/mail"); + expect(factory.requires).toContain("transport"); + expect(factory.requires).toContain("address"); + }); +}); + +// --------------------------------------------------------------------------- +// Delivery pipeline -- the message reaches the reactor +// --------------------------------------------------------------------------- + +// Director registry whose decide() records every `message.received` +// event and signals via the supplied counter. Used to assert that +// the harness's transport + deliver() paths actually surface the +// message into reactor decisions, not just into the fetch buffer. +function recordingDirectorRegistry(received: { count: number }) { + const defined = defineDirector({ + id: "@intx-test/harness/delivery-probe", + configSchema: type({}), + factory: () => ({ + async decide( + event: ReactorInboundEvent, + _state: ReactorState, + caps: ReactorCapabilities, + ) { + if (event.type === "message.received") { + received.count += 1; + return caps.done(); + } + return caps.wait(); + }, + }), + }); + return createDirectorRegistry({ + factories: [defined.factory], + defaultId: defined.factory.id, + }); +} + +function recordingDef() { + return defineAgent({ + id: "harness-delivery-probe", + systemPrompt: "test", + tools: [emptyMailFactory], + capabilities: [], + inference: { + sources: [{ provider: SOURCE.provider, model: SOURCE.model }], + }, + }); +} + +async function waitForReactorDone( + stream: AsyncIterable<{ type: string }>, +): Promise { + for await (const event of stream) { + if (event.type === "reactor.done") return; + } +} + +describe("createHarness message delivery", () => { + let workDir: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "harness-delivery-")); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + test("a watch 'exists' event surfaces the message to the reactor", async () => { + const { transport, control } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + const received = { count: 0 }; + const env: MailEnv = { + ...mailEnv({ workdir: workDir, storage, transport }), + directors: recordingDirectorRegistry(received), + }; + const harness = await createHarness(recordingDef(), env); + + try { + const message = createInboundMessage({ + from: "alice@example.com", + to: AGENT_ADDRESS, + content: "Hello", + interchangeType: "conversation.message", + }); + const stored: InboundMessage = { + ...message, + ref: { uid: 7, mailbox: "INBOX" }, + }; + control.enqueue(7, stored); + control.fireExists(7); + + const stream = harness.stream(); + await waitForReactorDone(stream); + expect(received.count).toBe(1); + } finally { + await harness.close(); + } + }); + + test("non-'exists' watch events do not produce a fetch or delivery", async () => { + const { transport, control } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + const received = { count: 0 }; + const env: MailEnv = { + ...mailEnv({ workdir: workDir, storage, transport }), + directors: recordingDirectorRegistry(received), + }; + const harness = await createHarness(recordingDef(), env); + + try { + // The mock's `fireExists` is the only event shape that + // should reach a `fetchFull`. The harness's watch callback + // checks `event.type === "exists"` and short-circuits + // otherwise -- so a callback yield with no fireExists must + // produce no fetches and no reactor deliveries. Holding + // off briefly gives any erroneous async fetch a chance to + // land before we assert. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(control.getFetchedUids().length).toBe(0); + expect(received.count).toBe(0); + } finally { + await harness.close(); + } + }); + + test("deliver() injects a message directly into the reactor", async () => { + const { transport } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + const received = { count: 0 }; + const env: MailEnv = { + ...mailEnv({ workdir: workDir, storage, transport }), + directors: recordingDirectorRegistry(received), + }; + const harness = await createHarness(recordingDef(), env); + + try { + const message = createInboundMessage({ + from: "alice@example.com", + to: AGENT_ADDRESS, + content: "Direct", + interchangeType: "conversation.message", + }); + harness.deliver(message); + await waitForReactorDone(harness.stream()); + expect(received.count).toBe(1); + } finally { + await harness.close(); + } + }); +}); + +// --------------------------------------------------------------------------- +// blobReader -- pass-through to the wrapped store +// --------------------------------------------------------------------------- + +describe("createHarness blobReader", () => { + let workDir: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "harness-blob-")); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + test("resolves a tool-output URI through the wrapped context store", async () => { + const { transport } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + await storage.writeBlob( + "abc123", + new TextEncoder().encode("spilled bytes"), + ); + const harness = await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport }), + ); + try { + const bytes = await harness.blobReader.read("tool-output:///abc123"); + expect(new TextDecoder().decode(bytes)).toBe("spilled bytes"); + } finally { + await harness.close(); + } + }); + + test("throws when the underlying store has no matching blob", async () => { + const { transport } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + const harness = await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport }), + ); + try { + let thrown: Error | undefined; + try { + await harness.blobReader.read("tool-output:///missing"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("Blob not found"); + } finally { + await harness.close(); + } + }); + + test("rejects malformed URIs without touching the store", async () => { + const { transport } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + let readCount = 0; + const originalReadBlob = storage.readBlob.bind(storage); + storage.readBlob = async (key, signal) => { + readCount += 1; + return originalReadBlob(key, signal); + }; + const harness = await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport }), + ); + try { + let thrown: Error | undefined; + try { + await harness.blobReader.read("file:///abc"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("invalid tool-output URI scheme"); + expect(readCount).toBe(0); + } finally { + await harness.close(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Workdir lock -- second createHarness on the same workdir is rejected +// --------------------------------------------------------------------------- + +describe("createHarness workdir lock", () => { + let workDir: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "harness-lock-")); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + test("rejects a second instance on the same workdir", async () => { + const { transport: transportA } = makeMockTransport(); + const { transport: transportB } = makeMockTransport(); + const storage = await createIsogitStore(workDir); + + const first = await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport: transportA }), + ); + + try { + let thrown: unknown; + try { + await createHarness( + emptyDef(), + mailEnv({ workdir: workDir, storage, transport: transportB }), + ); + } catch (cause) { + thrown = cause; + } + expect(thrown).toBeInstanceOf(AgentContextLockError); + } finally { + await first.close(); + } + }); +}); + +describe("createHarness reactor-once", () => { + // The composition-layer cross-check for the @intx/agent fixture + // suite: `createHarness(def, env)` must wrap the reactor exactly + // once per instantiation, the same invariant the planner.test.ts + // and mail.test.ts fixtures pin on the agent-only path. The + // mail-fixture docstring at packages/agent/src/internal-fixtures/ + // mail.test.ts:11-15 promises this assertion lives here so the + // agent package does not have to import @intx/harness (which would + // cycle the workspace dependency). + // + // The reactor count is a precise proxy for "the reactor assembly + // is wrapped exactly once": each createAgent (and therefore each + // createHarness, which delegates to it) resolves the director + // through the registry, calls the resolved factory once, and + // feeds the resulting director into createReactorAssembly. + + test("invokes the director factory exactly once per instantiation", async () => { + let factoryCallCount = 0; + const countingDefault = defineDirector({ + id: "@intx-harness-test/reactor-once/counting-default", + configSchema: type({}), + factory: (_config, env, agent) => { + factoryCallCount += 1; + return defaultDirectorFactory({}, env, agent); + }, + }); + const directors = createDirectorRegistry({ + factories: [countingDefault.factory], + defaultId: countingDefault.factory.id, + }); + + const workdir = mkdtempSync(join(tmpdir(), "harness-reactor-once-")); + try { + const { transport } = makeMockTransport(); + const storage = await createIsogitStore(workdir); + const harness = await createHarness(emptyDef(), { + ...mailEnv({ workdir, storage, transport }), + directors, + }); + try { + expect(factoryCallCount).toBe(1); + } finally { + await harness.close(); + } + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + }); +}); diff --git a/vendor/intx-harness/src/harness.ts b/vendor/intx-harness/src/harness.ts new file mode 100644 index 000000000..773dfa3cf --- /dev/null +++ b/vendor/intx-harness/src/harness.ts @@ -0,0 +1,462 @@ +// @intx/harness composition layer. +// +// The harness imports `@intx/agent` and composes a mail-transport +// surface on top of `createAgent(def, env)`. The reactor is wrapped +// exactly once -- inside the agent harness in `@intx/agent`. This +// module owns transport subscription, the connector router and its +// state persistence, the INBOX watch loop, and the outbound side of +// `connector.reply` events. +// +// What this module does *not* own: reactor wrapping, audit accumulation +// or flushing, source-registry hot-swap. Those live in `@intx/agent` +// and are reached via `agent.deliver`, `agent.setSource`, and +// `agent.stream()` respectively. + +import { + createAgent, + defineTool, + type Agent, + type AgentDefinition, + type AnnotatedToolFactory, + type BaseEnv, + type ToolBundle, + type ToolDeclaration, +} from "@intx/agent"; +import { getLogger } from "@intx/log"; +import type { + BlobReader, + ConnectorThreadState, + ContextStore, + InboundMessage, + InferenceSource, + MessageTransport, + Unsubscribe, +} from "@intx/types/runtime"; + +import { createConnectorRouter, type RouteDecision } from "./connector-router"; +import { driveConnectorReplies } from "./reply-drain"; + +const logger = getLogger(["interchange", "harness"]); + +/** + * Env extension the composition layer requires beyond `BaseEnv`. Tools + * shipped by this package declare the matching `requires` so + * `validateEnv` can blame either at the env entry point. + * + * `onReplySendFailed` is invoked when the reply drain catches a failure + * from `connectorRouter.composeReply` or `transport.send` for an + * outbound `connector.reply`. The reply is dropped and the router + * state is not advanced; the callback is the only programmatic surface + * a caller has to observe the loss. Production deployments that need + * retry semantics layer them on top of this callback. + * + * The callback may be synchronous or async; the reply drain awaits its + * resolution so an async callback's rejection is observed (and logged) + * rather than surfacing as an unhandled promise rejection. + * + * `onReplyDrainTerminated` is invoked when the reply drain's `for await` + * loop exits abnormally -- the only documented case is a + * `StreamBackpressureError` thrown by the agent's event stream when the + * drain's per-consumer buffer overruns `streamBufferMax`. After this + * fires the harness is no longer forwarding `connector.reply` events to + * the transport: in-process `agent.send()` callers still resolve, but + * outbound replies are silently dropped until `close()`. Production + * deployments that need to alert on this failure mode subscribe via + * this callback; the harness only emits a `logger.warn` otherwise. The + * callback may be synchronous or async and is awaited the same way + * `onReplySendFailed` is, so an async rejection is observed (and + * logged) rather than escaping as an unhandled rejection. + */ +export interface MailEnv extends BaseEnv { + transport: MessageTransport; + address: string; + onConnectorStateChanged?: (state: ConnectorThreadState | null) => void; + onReplySendFailed?: (cause: unknown) => void | Promise; + onReplyDrainTerminated?: (cause: unknown) => void | Promise; +} + +/** + * Narrowed public surface returned by `createHarness`. `close` is the + * only direct surface; everything else is a pass-through to the + * underlying agent. `stream` is exposed so observability consumers can + * subscribe to the reactor's event stream without having to grab the + * agent reference. + */ +export interface Harness { + close(): Promise; + deliver(message: InboundMessage): void; + setSource(source: InferenceSource): void; + setSources(sources: InferenceSource[], defaultSource: string): void; + stream: Agent["stream"]; + readonly blobReader: BlobReader; +} + +/** + * Mail-tool factory shape. The `createMailTools` constructor in + * `@intx/tools-mail` builds a runner from a transport-bearing + * capability set; the harness wraps that into a single `defineTool` + * bundle whose `requires` names the env keys the wrapper touches. + * + * Callers (e.g. the sidecar) supply the wrapper as a tool factory on + * their `AgentDefinition`. `createHarness` does not synthesize it + * internally -- the caller is the layer that knows which mail-tool + * implementation to use. + */ +export type MailToolWrapper = ( + transport: MessageTransport, +) => Omit; + +/** + * Build the `load` / `writeMetadata` overrides the harness layers onto + * `env.storage`. Extracted from `createHarness` so the dirty-bit gating + * on `load()` is directly testable -- the production path constructs + * the overrides inline with the same arguments. + * + * The `isInMemoryStateAuthoritative` callback is read on every `load` + * invocation. The harness sets the bit from the router's + * `onStateChanged` callback so the gate flips on the same tick a + * commit produces its first state change; subsequent loads (whether + * driven by reactor recovery, mid-cycle, or anywhere else) leave the + * router's in-memory snapshot intact rather than blanking it with the + * pre-commit disk value. + * + * Exported for the regression test in this package; no external + * consumer should call it. The helper is tightly coupled to the + * dirty-bit gating semantics that live in this module, and a separate + * testing entry-point would buy bundler ceremony for a boundary + * TypeScript cannot enforce. The docstring "internal" marker is the + * contract. + */ +export function createWrappedStorageOverrides( + baseStorage: ContextStore, + connectorRouter: ReturnType, + isInMemoryStateAuthoritative: () => boolean, +): Pick { + return { + async load(signal) { + const loaded = await baseStorage.load(signal); + if (!isInMemoryStateAuthoritative()) { + connectorRouter.restore(loaded.connectorState); + } + return loaded; + }, + async writeMetadata(metadata, signal) { + baseStorage.setConnectorState(connectorRouter.snapshot()); + return baseStorage.writeMetadata(metadata, signal); + }, + }; +} + +/** + * Construct an `AnnotatedToolFactory` for a mail-tool bundle. The + * factory binds `transport` from env at construction time and produces + * a bundle whose lifetime is tied to the agent. Disposal of the + * underlying mail tools is the caller's responsibility (the env is the + * agent's dependency contract; the caller owns what it puts in env); + * the agent itself does not call bundle disposers (see the + * `ToolBundle` contract in `@intx/agent`). Callers that need to + * dispose mail tools on shutdown retain a reference to the underlying + * `MailToolWrapper`'s output and invoke its `dispose` directly -- + * routing disposal through the bundle the agent receives would still + * not fire since the agent never holds it. + * + * The `requires: ["transport", "address"]` declaration captures the + * env-key surface of the entire mail composition path -- the factory + * body reads `transport`, and `createHarness` (which the caller pairs + * this factory with) reads `env.address` to label rejected-message + * log records identifying which agent's router refused the message. + * No routing decision keys off `env.address` -- the connector router + * routes on per-message thread state, not on the agent's own + * address -- so the field is observability-only. It still belongs in + * `requires` because the harness's log record assumes the field is + * populated; declaring it here lets the agent's `validateEnv` blame a + * missing `address` at construction time rather than letting the + * watch loop discover it under operational load. Callers that hand- + * build a `defineTool` factory for a different mail-tool runner must + * remember to surface `address` on their own `requires` if their + * `createHarness` consumes it -- the agent has no way to deduce + * composition-layer env requirements from a factory body that does + * not itself read the field. + * + * The `requires` set is fixed at the two keys above by design; this + * helper is not the extension point for mail-tool runners that need + * additional env keys. A mail tool that wants to read (say) a tenant + * identifier from env should drop down to `defineTool` directly, + * declare its own `requires` with the full surface, and call the + * underlying mail-tool constructor inside that factory. Folding an + * additional `requires` parameter into `defineMailTools` would push + * the "what does the harness need vs. what does the tool runner + * need" partition onto the caller, which is exactly the partition + * this helper exists to hide. + * + * `definitions` is the static declaration `defineTool` requires: the + * tool names this factory contributes, enumerable without invoking the + * wrapper. The caller supplies it because the wrapper binds `transport` + * from env and cannot run at declaration time; the caller already holds + * the mail-tool runner whose `definitions` name the same tools. + */ +export function defineMailTools( + wrapper: MailToolWrapper, + definitions: readonly ToolDeclaration[], +): AnnotatedToolFactory { + return defineTool({ + id: "@intx/harness/mail", + requires: ["transport", "address"], + definitions, + factory: (env) => { + const bundle = wrapper(env.transport); + return { + definitions: bundle.definitions, + run: (call, signal) => bundle.run(call, signal), + }; + }, + }); +} + +/** + * Construct a composition-layer agent: the underlying agent wrapped + * with connector-state-aware storage, transport subscription, INBOX + * watch, and connector-reply forwarding. + * + * The reactor is wrapped exactly once -- inside `createAgent`. + * `createHarness` augments env.storage with connector-state load/save + * and subscribes to the agent's event stream to intercept + * `connector.reply` events for outbound transport sends. + */ +export async function createHarness( + def: AgentDefinition, + env: EnvReq, +): Promise { + const transport = env.transport; + + // The wrappedStorage's load() needs to know whether the router's + // in-memory state is "fresher" than disk. The dirty bit flips on the + // first state change emitted by the router (commit() in the watch + // loop, onReplySent() after a connector.reply) and never flips back. + // Once dirty, the wrappedStorage refuses to restore from disk -- the + // router's in-memory state is authoritative. + // + // The wrappedStorage subscribes to the router's onStateChanged so the + // dirty bit is set the same tick commit() runs, even if a + // contextStore.load() races behind it. + let inMemoryStateAuthoritative = false; + const userOnStateChanged = env.onConnectorStateChanged; + const connectorRouter = createConnectorRouter({ + onStateChanged: (state) => { + inMemoryStateAuthoritative = true; + if (userOnStateChanged !== undefined) userOnStateChanged(state); + }, + }); + + // Wrap env.storage. The first load() restores connector state from + // disk only if no router commit has happened yet -- once a commit + // makes the router's state authoritative, subsequent loads return + // the store's payload unchanged and leave the in-memory state + // intact. + // + // The router's in-memory state diverges from disk between commit() + // (in the watch callback) and the next writeMetadata (at the + // reactor's per-cycle checkpoint). A load() landing in that window + // must not clobber the in-memory state with the stale disk value -- + // doing so makes the harness's outbound connector.reply path drop + // replies with NoActiveConnectorThreadError when composeReply() runs + // after a mid-cycle reload. + // + // The wrapper is implemented as a Proxy over env.storage so adding a + // new method to ContextStore does not require touching the harness: + // any method not named in `overrides` forwards to env.storage with + // its `this` bound to env.storage. The two overrides intercept + // load (cold-boot restore) and writeMetadata (flush router snapshot + // before delegate). `setConnectorState` is left to the default + // Proxy fall-through path since the harness adds no behaviour beyond + // delegation there. + const overrides = createWrappedStorageOverrides( + env.storage, + connectorRouter, + () => inMemoryStateAuthoritative, + ); + + const wrappedStorage: ContextStore = new Proxy(env.storage, { + get(target, prop, _receiver) { + if (prop === "load") return overrides.load; + if (prop === "writeMetadata") return overrides.writeMetadata; + const value = Reflect.get(target, prop, target); + // Bind methods to the underlying store so isogit-style + // closure-captured state and prototype-bound this both resolve + // against the real store, not the proxy. + return typeof value === "function" ? value.bind(target) : value; + }, + }); + + const agentEnv = { ...env, storage: wrappedStorage }; + + const agent = await createAgent(def, agentEnv); + + // From here through the final `return`, the agent is constructed + // and the workdir lock is held. Anything that throws -- the + // `driveConnectorReplies` setup, `transport.watch()`, + // anything in the watch callback's synchronous registration -- has + // to release the lock by closing the agent before re-raising; the + // caller never sees the agent and cannot do it themselves. + // `createAgent` covers its own internal failure paths via its + // `succeeded`/`finally` shape; this is the matching coverage for + // the harness's own construction tail. + let harnessSucceeded = false; + try { + // Background drain of the agent's event stream. Intercepts + // `connector.reply` to send the reply via transport; everything + // else flows past unobserved. Other consumers can subscribe to the + // exposed `stream()` method to see the same events. + // + // The shared `driveConnectorReplies` helper owns the loop: reply + // serialization (a second reply waits for the first's receipt to + // advance the router before composing its own), per-reply failure + // surfacing to `onReplySendFailed`, and abnormal-termination + // surfacing to `onReplyDrainTerminated`. The warm workflow-host + // path drives replies through the same helper. + const replyDrain = driveConnectorReplies({ + stream: agent.stream(), + composeReply: () => connectorRouter.composeReply(), + send: (message) => transport.send(message), + onReplySent: (receipt) => connectorRouter.onReplySent(receipt), + ...(env.onReplySendFailed !== undefined + ? { onSendFailed: env.onReplySendFailed } + : {}), + ...(env.onReplyDrainTerminated !== undefined + ? { onTerminated: env.onReplyDrainTerminated } + : {}), + }); + + // Delete a message from the INBOX after it has been delivered to the + // reactor. + // + // A failure here is logged and swallowed: the router state has + // already been committed and `agent.deliver` has accepted the + // message, so re-raising would unwind a half-applied delivery. The + // message stays in the INBOX and a future startup (or watch firing) + // re-fetches it, re-routes it, and re-delivers it. The router's + // persisted state makes that benign on the routing side: the sender + // is already a thread participant, so `route()` returns either a + // `continue` (which is a no-op state mutation since the sender is + // unchanged) or a `passthrough` (no headers match). The agent's + // director sees a duplicate `message.received`; idempotent + // directors are unaffected, and the audit trail records the + // duplicate for post-hoc reconciliation. + async function consumeFromInbox(message: InboundMessage): Promise { + try { + await transport.setFlags(message.ref, ["\\Deleted"]); + await transport.expunge("INBOX"); + } catch (cause) { + logger.warn`Failed to consume message uid=${message.ref.uid} from INBOX: ${cause}`; + } + } + + // INBOX watch loop. Subscribe before the agent's reactor is fully + // settled so no message is missed in the window between subscription + // and the first watch callback. + let stopped = false; + const unsubscribe: Unsubscribe = transport.watch("INBOX", (event) => { + if (stopped) return; + if (event.type !== "exists") return; + + const ref = { uid: event.uid, mailbox: "INBOX" }; + + void (async () => { + try { + let message: InboundMessage; + try { + message = await transport.fetchFull(ref); + } catch (cause) { + logger.error`Failed to fetch message uid=${event.uid}: ${cause}`; + return; + } + + if (stopped) return; + + let decision: RouteDecision; + try { + decision = connectorRouter.route(message); + } catch (cause) { + // A router-rejected message (malformed headers, parse error + // inside the router, etc.) is still surfaced to the agent + // as an inbound `message.received`. The agent's director + // decides what the message means and how to respond; + // dropping it on the floor here would hide messages the + // operator may want to see. The router's state is *not* + // committed for the rejected message, so subsequent replies + // compose against the pre-rejection thread state. + logger.warn`Connector router rejected message uid=${message.ref.uid} for agent ${env.address}: ${cause instanceof Error ? cause.message : String(cause)}`; + if (stopped) return; + agent.deliver(message); + return; + } + + if (decision.kind === "passthrough") { + if (stopped) return; + agent.deliver(message); + return; + } + + // start or continue: commit router state synchronously before + // any await so a concurrent watch callback observes the + // updated state. + connectorRouter.commit(decision); + if (stopped) return; + agent.deliver(message); + await consumeFromInbox(message); + } catch (cause) { + // `agent.deliver` throws `AgentClosedError` synchronously when + // called after the agent has closed. The `if (stopped) return` + // guards above narrow the race window but cannot close it: a + // `close()` call landing between the guard and the synchronous + // throw still surfaces the rejection here. The fetched message + // is dropped; close() is in progress and the harness is + // tearing down, so the loss is expected. Without this catch + // the rejection would escape the void-IIFE as an unhandled + // promise rejection on the event loop. + if (cause instanceof Error && cause.name === "AgentClosedError") { + logger.warn`INBOX watch dropped uid=${event.uid} because the agent closed mid-delivery`; + return; + } + logger.error`INBOX watch failed for uid=${event.uid}: ${cause}`; + } + })(); + }); + + async function close(): Promise { + if (stopped) return; + stopped = true; + unsubscribe(); + replyDrain.stop(); + await agent.close(); + // The reply-drain loop exits once the underlying stream closes + // (close() above terminates streamConsumers). Awaiting here makes + // close idempotent and lets callers rely on a settled state. + await replyDrain.done; + } + + const harness: Harness = { + close, + deliver: (message) => agent.deliver(message), + setSource: (source) => agent.setSource(source), + setSources: (sources, defaultSource) => + agent.setSources(sources, defaultSource), + stream: () => agent.stream(), + blobReader: agent.blobReader, + }; + harnessSucceeded = true; + return harness; + } finally { + if (!harnessSucceeded) { + // Close the agent without waiting on its shutdown timeout so a + // synchronous post-`createAgent` throw does not stall the + // caller's failure path. The `.catch` swallows any rejection + // from the close: the caller is already receiving the original + // throw, and a noisier-than-original close failure here would + // mask it. + void agent.close().catch(() => { + // Swallow per the comment above. + }); + } + } +} diff --git a/vendor/intx-harness/src/index.ts b/vendor/intx-harness/src/index.ts new file mode 100644 index 000000000..6ea215ab4 --- /dev/null +++ b/vendor/intx-harness/src/index.ts @@ -0,0 +1,50 @@ +export { + createHarness, + defineMailTools, + type Harness, + type MailEnv, + type MailToolWrapper, +} from "./harness"; + +export { createHarnessRuntimeCapabilities } from "./runtime-capabilities"; +export type { HarnessRuntimeCapabilitiesOptions } from "./runtime-capabilities"; + +export { + createCredentialProviderRegistry, + createHttpCredentialProvider, + builtinCredentialProviders, +} from "./credential-providers"; +export type { + CredentialProviderRegistry, + FetchLike, + HttpCredentialProviderOptions, +} from "./credential-providers"; + +export { + createCredentialCapability, + reconcileDeclaredCredentials, +} from "./credential-capability"; +export type { + CredentialCapabilityDeps, + HostCredentialCapability, + ResolvedCredentialBinding, +} from "./credential-capability"; + +export { + createConnectorRouter, + NoActiveConnectorThreadError, +} from "./connector-router"; +export type { + ConnectorRouter, + ConnectorReplyParts, + ConnectorRouterOptions, + RouteDecision, +} from "./connector-router"; + +export { driveConnectorReplies } from "./reply-drain"; +export type { + AgentEventStream, + ConnectorReplyDrain, + ConnectorReplyDrainOpts, + ReplySettlement, +} from "./reply-drain"; diff --git a/vendor/intx-harness/src/reply-drain.test.ts b/vendor/intx-harness/src/reply-drain.test.ts new file mode 100644 index 000000000..d09bc8218 --- /dev/null +++ b/vendor/intx-harness/src/reply-drain.test.ts @@ -0,0 +1,468 @@ +// Unit tests for the shared connector reply drain. The drain is exercised +// against a plain async stream and stub compose/send/onReplySent seams -- +// no full agent or transport -- so the contract (one send per +// connector.reply, correct threading headers, serialized ordering, and a +// surfaced send failure) is asserted in isolation. + +import { describe, expect, test } from "bun:test"; + +import type { + InferenceEvent, + OutboundMessage, + SendReceipt, +} from "@intx/types/runtime"; + +import type { ConnectorReplyParts } from "./connector-router"; +import { driveConnectorReplies } from "./reply-drain"; + +function replyEvent(seq: number, content: string): InferenceEvent { + return { type: "connector.reply", seq, data: { content } }; +} + +// A non-reply event the drain must ignore. `reactor.start` carries an empty +// data object, so it is the cheapest event to interleave. +function noiseEvent(seq: number): InferenceEvent { + return { type: "reactor.start", seq, data: {} }; +} + +async function* streamOf( + events: InferenceEvent[], +): AsyncGenerator { + for (const event of events) { + yield event; + // Yield to the microtask queue between events so the drain's reply chain + // has a chance to interleave, matching the real agent stream's async + // delivery. + await Promise.resolve(); + } +} + +/** + * A push-based event stream: a test feeds events with `push` and ends the + * stream with `end`, so the barrier's capture-then-await ordering can be + * exercised against a stream that stays open between events. + */ +function pushStream(): { + stream: AsyncGenerator; + push: (event: InferenceEvent) => void; + end: () => void; +} { + const queue: InferenceEvent[] = []; + let notify: (() => void) | null = null; + let ended = false; + const wake = (): void => { + const resume = notify; + notify = null; + resume?.(); + }; + async function* gen(): AsyncGenerator { + for (;;) { + const next = queue.shift(); + if (next !== undefined) { + yield next; + continue; + } + if (ended) return; + await new Promise((resolve) => { + notify = resolve; + }); + } + } + return { + stream: gen(), + push(event) { + queue.push(event); + wake(); + }, + end() { + ended = true; + wake(); + }, + }; +} + +/** Resolve after enough ticks for the drain's chain to settle a pushed reply. */ +async function settleTicks(): Promise { + for (let i = 0; i < 5; i += 1) await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 10)); +} + +describe("driveConnectorReplies", () => { + test("sends exactly one threaded reply per connector.reply", async () => { + const sent: OutboundMessage[] = []; + const receipts: SendReceipt[] = []; + const parts: ConnectorReplyParts = { + to: "alice@example.com", + cc: ["bob@example.com"], + inReplyTo: "", + subject: "Re: hello", + }; + + const drain = driveConnectorReplies({ + stream: streamOf([ + noiseEvent(1), + replyEvent(2, "the reply body"), + noiseEvent(3), + ]), + composeReply: () => parts, + send: async (message) => { + sent.push(message); + return { messageId: "", status: "delivered" }; + }, + onReplySent: (receipt) => { + receipts.push(receipt); + }, + }); + + await drain.done; + + expect(sent.length).toBe(1); + const message = sent[0]; + expect(message).toBeDefined(); + expect(message?.type).toBe("conversation.message"); + expect(message?.content).toBe("the reply body"); + expect(message?.to).toBe("alice@example.com"); + expect(message?.cc).toEqual(["bob@example.com"]); + expect(message?.inReplyTo).toBe(""); + expect(message?.subject).toBe("Re: hello"); + + expect(receipts.length).toBe(1); + expect(receipts[0]?.messageId).toBe(""); + }); + + test("sets the full References chain a supplied resolver returns", async () => { + const sent: OutboundMessage[] = []; + const resolvedFor: string[] = []; + + const drain = driveConnectorReplies({ + stream: streamOf([replyEvent(1, "threaded reply")]), + composeReply: () => ({ + to: "alice@example.com", + cc: [], + inReplyTo: "", + }), + resolveReferences: async (inReplyTo) => { + resolvedFor.push(inReplyTo); + return [ + "", + "", + "", + ]; + }, + send: async (message) => { + sent.push(message); + return { messageId: "", status: "delivered" }; + }, + onReplySent: () => undefined, + }); + + await drain.done; + + // The resolver is consulted with the parent id from composeReply, and the + // full ancestry it returns rides onto the outbound message verbatim. + expect(resolvedFor).toEqual([""]); + expect(sent[0]?.references).toEqual([ + "", + "", + "", + ]); + }); + + test("omits references when the resolver reports no parent", async () => { + // A resolver miss (first reply on a fresh thread, malformed id) returns + // undefined; the drain then leaves `references` unset so the transport + // derives a single-element chain from inReplyTo. + const sent: OutboundMessage[] = []; + + const drain = driveConnectorReplies({ + stream: streamOf([replyEvent(1, "first on the thread")]), + composeReply: () => ({ + to: "alice@example.com", + cc: [], + inReplyTo: "", + }), + resolveReferences: async () => undefined, + send: async (message) => { + sent.push(message); + return { messageId: "", status: "delivered" }; + }, + onReplySent: () => undefined, + }); + + await drain.done; + + expect(sent).toHaveLength(1); + expect(sent[0]?.references).toBeUndefined(); + }); + + test("omits references with no resolver supplied", async () => { + // The createHarness path supplies no resolver; the drain must leave + // `references` unset, preserving the pre-existing single-element + // threading the transport derives from inReplyTo. + const sent: OutboundMessage[] = []; + + const drain = driveConnectorReplies({ + stream: streamOf([replyEvent(1, "no resolver")]), + composeReply: () => ({ + to: "alice@example.com", + cc: [], + inReplyTo: "", + }), + send: async (message) => { + sent.push(message); + return { messageId: "", status: "delivered" }; + }, + onReplySent: () => undefined, + }); + + await drain.done; + + expect(sent).toHaveLength(1); + expect(sent[0]?.references).toBeUndefined(); + }); + + test("serializes replies so each composes against the advanced thread", async () => { + // A minimal connector-thread model: `onReplySent` advances the parent id + // that the next `composeReply` threads against. If the drain did not + // serialize compose -> send -> onReplySent, the second reply would + // compose against the pre-advance id. + let lastMessageId = ""; + let nextChildSeq = 0; + const inReplyToSeen: string[] = []; + + const drain = driveConnectorReplies({ + stream: streamOf([replyEvent(1, "first"), replyEvent(2, "second")]), + composeReply: () => { + inReplyToSeen.push(lastMessageId); + return { to: "alice@example.com", cc: [], inReplyTo: lastMessageId }; + }, + send: async () => { + nextChildSeq += 1; + return { + messageId: ``, + status: "delivered", + }; + }, + onReplySent: (receipt) => { + lastMessageId = receipt.messageId; + }, + }); + + await drain.done; + + expect(inReplyToSeen).toEqual([ + "", + "", + ]); + }); + + test("surfaces a send failure and leaves the thread unadvanced", async () => { + const sentinel = new Error("outbound bridge rejected the send"); + const failures: unknown[] = []; + let onReplySentCalled = false; + + const drain = driveConnectorReplies({ + stream: streamOf([replyEvent(1, "will fail")]), + composeReply: () => ({ + to: "alice@example.com", + cc: [], + inReplyTo: "", + }), + send: async () => { + throw sentinel; + }, + onReplySent: () => { + onReplySentCalled = true; + }, + onSendFailed: (cause) => { + failures.push(cause); + }, + }); + + await drain.done; + + expect(failures).toEqual([sentinel]); + // A failed send must not advance the connector thread. + expect(onReplySentCalled).toBe(false); + }); + + test("does not let a send failure reject the done promise", async () => { + // The drain must keep running past a per-reply failure: `done` resolves, + // and a later reply still sends. A rejecting `done` would take down the + // caller that awaits it on teardown. + const sent: OutboundMessage[] = []; + let failCount = 0; + + const drain = driveConnectorReplies({ + stream: streamOf([replyEvent(1, "boom"), replyEvent(2, "ok")]), + composeReply: () => ({ + to: "alice@example.com", + cc: [], + inReplyTo: "", + }), + send: async (message) => { + if (message.content === "boom") { + failCount += 1; + throw new Error("first send fails"); + } + sent.push(message); + return { messageId: "", status: "delivered" }; + }, + onReplySent: () => undefined, + }); + + await expect(drain.done).resolves.toBeUndefined(); + expect(failCount).toBe(1); + expect(sent.length).toBe(1); + expect(sent[0]?.content).toBe("ok"); + }); + + test("stop() halts the loop before the next event's reply", async () => { + // `stop()` is the cooperative early exit a teardown uses before the + // stream ends on its own. After it is set, the loop breaks at the next + // event rather than sending its reply. + const sent: OutboundMessage[] = []; + let stopHandle: (() => void) | null = null; + + async function* controlledStream(): AsyncGenerator { + yield replyEvent(1, "first"); + await Promise.resolve(); + // Stop before the second reply is observed. + stopHandle?.(); + yield replyEvent(2, "second"); + } + + const drain = driveConnectorReplies({ + stream: controlledStream(), + composeReply: () => ({ + to: "alice@example.com", + cc: [], + inReplyTo: "", + }), + send: async (message) => { + sent.push(message); + return { messageId: "", status: "delivered" }; + }, + onReplySent: () => undefined, + }); + stopHandle = drain.stop; + + await drain.done; + + expect(sent.length).toBe(1); + expect(sent[0]?.content).toBe("first"); + }); + + test("the settle barrier resolves with the receipt after the send acks", async () => { + // The per-turn barrier: a caller snapshots `replySeq()` before the reply, + // then awaits `waitForReplyAfter(snapshot)`, which resolves only once the + // reply's send has acked and the thread advanced. + const { stream, push, end } = pushStream(); + let sendAcked = false; + + const drain = driveConnectorReplies({ + stream, + composeReply: () => ({ + to: "alice@example.com", + cc: [], + inReplyTo: "", + }), + send: async () => { + sendAcked = true; + return { messageId: "", status: "delivered" }; + }, + onReplySent: () => undefined, + }); + + // Snapshot before the reply is pushed: no reply has settled yet. + const before = drain.replySeq(); + expect(before).toBe(0); + const barrier = drain.waitForReplyAfter(before); + + push(replyEvent(1, "the reply")); + const settlement = await barrier; + + // The barrier resolved only after the send acked, and carries the receipt. + expect(sendAcked).toBe(true); + expect(settlement.ok).toBe(true); + if (settlement.ok) { + expect(settlement.receipt.messageId).toBe(""); + } + // The sequence advanced by exactly one. + expect(drain.replySeq()).toBe(1); + + end(); + await drain.done; + }); + + test("a turn that emits no reply does not settle the barrier until teardown", async () => { + // A suspended / no-reply turn produces no `connector.reply`, so the reply + // sequence does not advance and a barrier awaiting that reply stays pending + // -- which is why the warm step must NOT await the barrier for such a turn. + // When the drain ends before the reply arrives, the barrier resolves as a + // failure rather than hanging forever. + const { stream, push, end } = pushStream(); + + const drain = driveConnectorReplies({ + stream, + composeReply: () => ({ + to: "alice@example.com", + cc: [], + inReplyTo: "", + }), + send: async () => ({ + messageId: "", + status: "delivered", + }), + onReplySent: () => undefined, + }); + + const barrier = drain.waitForReplyAfter(0); + let settledEarly = false; + void barrier.then(() => { + settledEarly = true; + }); + + // A non-reply event flows past without advancing the reply sequence. + push(noiseEvent(1)); + await settleTicks(); + expect(settledEarly).toBe(false); + expect(drain.replySeq()).toBe(0); + + // Teardown before any reply arrives resolves the barrier as a failure. + end(); + const settlement = await barrier; + expect(settlement.ok).toBe(false); + await drain.done; + }); + + test("a failed send settles the barrier as not-sent", async () => { + // A send failure must NOT count as a durable reply: the barrier resolves + // with `ok: false` carrying the cause, distinct from a successful ack, so a + // per-turn caller fails the turn rather than treating the reply as sent. + const sentinel = new Error("outbound bridge rejected the send"); + + const drain = driveConnectorReplies({ + stream: streamOf([replyEvent(1, "will fail")]), + composeReply: () => ({ + to: "alice@example.com", + cc: [], + inReplyTo: "", + }), + send: async () => { + throw sentinel; + }, + onReplySent: () => undefined, + }); + + await drain.done; + + // The reply settled (the sequence advanced) but as a failure. + expect(drain.replySeq()).toBe(1); + const settlement = await drain.waitForReplyAfter(0); + expect(settlement.ok).toBe(false); + if (!settlement.ok) { + expect(settlement.cause).toBe(sentinel); + } + }); +}); diff --git a/vendor/intx-harness/src/reply-drain.ts b/vendor/intx-harness/src/reply-drain.ts new file mode 100644 index 000000000..8c604a064 --- /dev/null +++ b/vendor/intx-harness/src/reply-drain.ts @@ -0,0 +1,315 @@ +// Shared connector reply drain for the agent harness. +// +// A director emits a `connector.reply` event when the agent produces an +// outbound reply on its connector thread. Draining that event means: +// compose the threading headers for the active thread, send the reply +// through the transport, then advance the thread's `lastMessageId` from +// the send receipt. This module owns that loop so both the harness +// composition layer (`createHarness`) and the warm workflow-host agent +// path drive replies through one implementation rather than each keeping +// its own copy. +// +// The loop subscribes an agent event stream and serializes every reply +// through a single chain: two replies fired in quick succession do not +// interleave their compose / send / onReplySent sequence -- the second +// waits for the first's receipt to advance the thread before composing +// against it. A per-reply failure (compose, send, or onReplySent) is +// surfaced to `onSendFailed` and the reply is dropped with the thread left +// at its pre-send state; an abnormal stream termination (e.g. an agent +// stream backpressure violation) is surfaced to `onTerminated`. Neither +// escapes the returned `done` promise -- it always resolves -- so a caller +// can await teardown without guarding a rejection. + +import type { Agent } from "@intx/agent"; +import { getLogger } from "@intx/log"; +import type { OutboundMessage, SendReceipt } from "@intx/types/runtime"; + +import type { ConnectorReplyParts } from "./connector-router"; + +const logger = getLogger(["interchange", "harness", "reply-drain"]); + +/** + * The agent event stream the drain consumes -- exactly `agent.stream()`'s + * type. The stream yields the reactor's full emitted-event union (wider than + * `InferenceEvent`: it also carries `message.received`), so the drain accepts + * that union and lets every non-`connector.reply` event flow past untouched. + */ +export type AgentEventStream = ReturnType; + +export interface ConnectorReplyDrainOpts { + /** The agent event stream to drain. Each `connector.reply` sends a reply. */ + stream: AgentEventStream; + /** + * Produce the threading headers (`to`, `cc`, `inReplyTo`, `subject`) for + * the active connector thread. Throws when no thread is active; the throw + * is caught per reply and routed to `onSendFailed`. + */ + composeReply: () => ConnectorReplyParts; + /** + * Send the composed reply. The drain builds the `OutboundMessage` from + * `composeReply()`'s parts plus the reply content and a + * `conversation.message` type; the caller's `send` routes it to the + * transport / outbound bridge. + */ + send: (message: OutboundMessage) => Promise; + /** + * Resolve the full RFC 5322 References chain for a reply whose parent is + * `inReplyTo` (the Message-Id of the message being answered). Returns the + * parent's own References plus the parent's Message-Id, in order, so the + * outbound reply carries the complete conversational ancestry rather than + * a truncated single element. Returns `undefined` when the parent cannot be + * located (the very first reply on a fresh thread, or a malformed id); the + * drain then omits `references` and the transport derives `[inReplyTo]`. + * + * Optional: a caller with no mailbox to consult (`createHarness`) omits it, + * leaving the pre-existing single-element threading unchanged. The warm + * workflow-host wiring supplies it from the deployment's committed mailbox. + */ + resolveReferences?: (inReplyTo: string) => Promise; + /** + * Advance connector state after a successful send. May be synchronous + * (the in-process router's `onReplySent`) or asynchronous (a durable + * store that persists the advanced `lastMessageId`); the drain awaits it + * before composing the next reply. + */ + onReplySent: (receipt: SendReceipt) => void | Promise; + /** + * Invoked when `composeReply`, `send`, or `onReplySent` throws for one + * reply. The reply is dropped and the connector thread stays at its + * pre-send value. The drain awaits the callback (so an async callback's + * rejection is observed and logged, not left as an unhandled rejection) + * and absorbs any error it raises. + */ + onSendFailed?: (cause: unknown) => void | Promise; + /** + * Invoked when the stream's `for await` loop exits abnormally -- the + * documented case is a backpressure error thrown by the agent event + * stream. After it fires the drain no longer forwards replies. Awaited + * and absorbed the same way as `onSendFailed`. + */ + onTerminated?: (cause: unknown) => void | Promise; +} + +/** + * The settled outcome of one reply the drain processed. `ok` distinguishes a + * durably-sent reply (the send acked and `onReplySent` advanced the thread) + * from a failed one (compose, send, or `onReplySent` threw). A caller gating a + * side effect on the reply reaching the transport awaits the barrier and acts + * only on `ok: true`; `ok: false` carries the failure `cause` so the caller can + * surface it rather than treat the reply as sent. + */ +export type ReplySettlement = + | { readonly ok: true; readonly receipt: SendReceipt } + | { readonly ok: false; readonly cause: unknown }; + +export interface ConnectorReplyDrain { + /** + * Settles once the drain loop has exited and its last pending reply has + * drained. Always resolves -- per-reply and terminal failures are routed + * to the callbacks, never thrown out of here -- so a caller can await it + * on teardown without guarding a rejection. + */ + readonly done: Promise; + /** + * Signal the loop to stop at the next event. The loop also exits on its + * own when the underlying stream ends (e.g. the agent closes); `stop()` + * is the cooperative early exit for a caller tearing down before then. + */ + stop(): void; + /** + * The count of replies that have SETTLED so far -- sent-and-acked or failed. + * Monotonic. A per-turn caller captures this BEFORE the `agent.send` that may + * produce a reply, then, for a turn that did produce a `connector.reply`, + * awaits `waitForReplyAfter(captured)` to block until THIS turn's reply + * settles. The capture-before-send ordering is required: the agent resolves + * `agent.send` in the same synchronous step that pushes the `connector.reply` + * onto this drain's stream, so the reply is not yet enqueued when `send` + * resolves -- a post-send snapshot would miss it. + */ + replySeq(): number; + /** + * Resolve once more than `n` replies have settled -- i.e. the reply at index + * `n` (the `(n + 1)`th reply the drain processed) has settled -- with that + * reply's settlement. Because the warm agent is strictly serial and the drain + * is FIFO, a turn that captured `n` from `replySeq()` before its send and + * produced exactly one reply awaits reply `n` here. + * + * When the drain loop exits (stream end, `stop()`, or an abnormal + * termination) before reply `n` settles, resolves with a failure settlement + * rather than hanging, so a caller awaiting a reply that will never arrive + * fails its turn instead of blocking forever. + */ + waitForReplyAfter(n: number): Promise; +} + +async function invokeAbsorbing( + callback: (cause: unknown) => void | Promise, + cause: unknown, + label: string, +): Promise { + try { + await callback(cause); + } catch (callbackError) { + logger.error`${label} callback threw: ${callbackError}`; + } +} + +/** + * Drive an agent's `connector.reply` events out through a transport. Returns + * immediately with a handle; the drain runs in the background until the + * stream ends or `stop()` is called. + */ +export function driveConnectorReplies( + opts: ConnectorReplyDrainOpts, +): ConnectorReplyDrain { + let stopped = false; + // Reply sends are serialized through `replyChain` so two replies fired in + // quick succession do not interleave their compose / send / onReplySent + // sequence -- the second waits for the first's receipt to advance the + // thread before composing its own. + let replyChain: Promise = Promise.resolve(); + + // Per-turn settle barrier. `settlements[i]` is the outcome of the `i`th reply + // the drain processed; `settlements.length` is the monotonic settled count a + // caller snapshots through `replySeq()`. Waiters block until the settled + // count passes their target index, then resolve with that reply's outcome. A + // reply is recorded here on BOTH success and failure so a waiter never hangs; + // the outcome's `ok` tells the caller which happened. + const settlements: ReplySettlement[] = []; + let terminated = false; + type Waiter = { target: number; resolve: (s: ReplySettlement) => void }; + let waiters: Waiter[] = []; + + const terminalSettlement = (): ReplySettlement => ({ + ok: false, + cause: new Error( + "connector reply drain terminated before the reply was sent", + ), + }); + + function settlementAt(index: number): ReplySettlement { + const settlement = settlements[index]; + if (settlement === undefined) { + // Reached only if a waiter resolves for an index the drain never + // recorded -- an internal invariant break, surfaced loudly rather than + // handed back as a silent fallback. + throw new Error( + `connector reply drain: settlement ${String(index)} missing though ` + + `${String(settlements.length)} replies have settled`, + ); + } + return settlement; + } + + function recordSettlement(settlement: ReplySettlement): void { + settlements.push(settlement); + const settledCount = settlements.length; + const stillWaiting: Waiter[] = []; + for (const waiter of waiters) { + if (settledCount > waiter.target) { + waiter.resolve(settlementAt(waiter.target)); + } else { + stillWaiting.push(waiter); + } + } + waiters = stillWaiting; + } + + function releaseWaitersOnTermination(): void { + terminated = true; + const outstanding = waiters; + waiters = []; + for (const waiter of outstanding) { + // A waiter whose reply settled before teardown gets its real outcome; one + // whose reply never arrived (the drain stopped first) gets a terminal + // failure so the caller fails its turn rather than blocking. + waiter.resolve( + settlements.length > waiter.target + ? settlementAt(waiter.target) + : terminalSettlement(), + ); + } + } + + const done = (async () => { + try { + for await (const event of opts.stream) { + if (stopped) break; + if (event.type !== "connector.reply") continue; + const content = event.data.content; + replyChain = replyChain.then(async () => { + try { + const parts = opts.composeReply(); + // Resolve the full References ancestry for the parent this reply + // answers, when the caller supplies a resolver. A resolver miss + // (parent absent, malformed id) yields `undefined`, and the + // transport derives `[inReplyTo]` as before. + const references = + opts.resolveReferences !== undefined + ? await opts.resolveReferences(parts.inReplyTo) + : undefined; + const receipt = await opts.send({ + ...parts, + content, + type: "conversation.message", + ...(references !== undefined && references.length > 0 + ? { references } + : {}), + }); + await opts.onReplySent(receipt); + recordSettlement({ ok: true, receipt }); + } catch (cause) { + // The reply is dropped and the connector thread stays at its + // pre-send value. Surface the loss to `onSendFailed` in addition + // to the operator-facing log so programmatic consumers (retries, + // alerting) can observe what the log alone hides. Record the + // failure on the barrier too, so a per-turn caller awaiting this + // reply sees `ok: false` rather than treating it as sent. + logger.error`Failed to send connector reply: ${cause}`; + if (opts.onSendFailed !== undefined) { + await invokeAbsorbing(opts.onSendFailed, cause, "onSendFailed"); + } + recordSettlement({ ok: false, cause }); + } + }); + } + } catch (cause) { + // The agent's stream throws on backpressure violations; log and exit. + // The reply path stops working but the caller's other consumers keep + // running until teardown. Surface the loss to `onTerminated` so + // programmatic consumers (alerting, watchdogs) can observe it. + logger.warn`Reply-drain stream terminated: ${cause}`; + if (opts.onTerminated !== undefined) { + await invokeAbsorbing(opts.onTerminated, cause, "onTerminated"); + } + } finally { + // Drain the pending reply before the loop exits so its settlement is + // recorded and a caller awaiting `done` sees a settled state. Then + // release any barrier waiter still blocked on a reply that will never + // arrive, so a per-turn caller cannot hang past teardown. + await replyChain; + releaseWaitersOnTermination(); + } + })(); + + return { + done, + stop() { + stopped = true; + }, + replySeq() { + return settlements.length; + }, + waitForReplyAfter(n: number): Promise { + if (settlements.length > n) { + return Promise.resolve(settlementAt(n)); + } + if (terminated) { + return Promise.resolve(terminalSettlement()); + } + return new Promise((resolve) => { + waiters.push({ target: n, resolve }); + }); + }, + }; +} diff --git a/vendor/intx-harness/src/runtime-capabilities.test.ts b/vendor/intx-harness/src/runtime-capabilities.test.ts new file mode 100644 index 000000000..3cda20ff0 --- /dev/null +++ b/vendor/intx-harness/src/runtime-capabilities.test.ts @@ -0,0 +1,19 @@ +import { describe, test, expect } from "bun:test"; +import type { MessageTransport } from "@intx/types/runtime"; + +import { createHarnessRuntimeCapabilities } from "./runtime-capabilities"; + +// Minimal stand-in for MessageTransport. The factory passes the handle +// through; it does not invoke any methods on it. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only stand-in; factory never calls these methods +const stubTransport = {} as unknown as MessageTransport; + +describe("createHarnessRuntimeCapabilities", () => { + test("resolve('mail.transport') returns the supplied transport reference", () => { + const capabilities = createHarnessRuntimeCapabilities({ + transport: stubTransport, + }); + + expect(capabilities.resolve("mail.transport")).toBe(stubTransport); + }); +}); diff --git a/vendor/intx-harness/src/runtime-capabilities.ts b/vendor/intx-harness/src/runtime-capabilities.ts new file mode 100644 index 000000000..41ee95fd6 --- /dev/null +++ b/vendor/intx-harness/src/runtime-capabilities.ts @@ -0,0 +1,22 @@ +// Harness-side factory for the RuntimeCapabilities that tool packages +// consume. The wrapper exists so callers (sidecar, alternate runtimes) +// pass a config object keyed by domain (`transport`) and the harness +// owns the translation to RuntimeCapabilityMap keys (`mail.transport`). +// When new capabilities are added, callers' shapes evolve through this +// wrapper, not at the call site. + +import { + createRuntimeCapabilities, + type RuntimeCapabilities, +} from "@intx/types/runtime-capabilities"; +import type { MessageTransport } from "@intx/types/runtime"; + +export interface HarnessRuntimeCapabilitiesOptions { + transport: MessageTransport; +} + +export function createHarnessRuntimeCapabilities( + opts: HarnessRuntimeCapabilitiesOptions, +): RuntimeCapabilities { + return createRuntimeCapabilities({ "mail.transport": opts.transport }); +} diff --git a/vendor/intx-harness/tsconfig.json b/vendor/intx-harness/tsconfig.json new file mode 100644 index 000000000..9e25e6ece --- /dev/null +++ b/vendor/intx-harness/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/vendor/intx-mailbox/LICENSE b/vendor/intx-mailbox/LICENSE new file mode 100644 index 000000000..c6487f4fd --- /dev/null +++ b/vendor/intx-mailbox/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/vendor/intx-mailbox/README.md b/vendor/intx-mailbox/README.md new file mode 100644 index 000000000..9a6a103e6 --- /dev/null +++ b/vendor/intx-mailbox/README.md @@ -0,0 +1,30 @@ +# @intx/mailbox + +Storage-agnostic IMAP mailbox model. A `MailboxStore` backing owns how a +mailbox's message list and its uid/modseq/uidValidity counters are stored; the +pure query and projection functions read the message snapshot the backing +exposes. + +The package ships one reference backing, `createInMemoryMailboxStore`, which +keeps messages and counters in process memory. Other backings (for example a +persistent substrate) implement the same `MailboxStore` interface, so the +search, threading, and fetch logic is written once and reused across every +backing. + +```ts +import { + createInMemoryMailboxStore, + executeSearch, + fetchFull, +} from "@intx/mailbox"; + +const store = createInMemoryMailboxStore(); +const uid = store.append(rawMessageBytes, envelope, []); + +const hits = executeSearch("INBOX", store, { from: "alpha@local.interchange" }); +const message = await fetchFull({ uid, mailbox: "INBOX" }, store, getCrypto); +``` + +The projections parse from the stored RFC 2822 bytes, so +`@intx/mailbox` produces the same envelope, structure, and signature results +regardless of which backing holds the message. diff --git a/vendor/intx-mailbox/package.json b/vendor/intx-mailbox/package.json new file mode 100644 index 000000000..794b2eed3 --- /dev/null +++ b/vendor/intx-mailbox/package.json @@ -0,0 +1,18 @@ +{ + "name": "@intx/mailbox", + "version": "0.2.2", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:" + } +} diff --git a/vendor/intx-mailbox/src/fetch.test.ts b/vendor/intx-mailbox/src/fetch.test.ts new file mode 100644 index 000000000..33b91eefa --- /dev/null +++ b/vendor/intx-mailbox/src/fetch.test.ts @@ -0,0 +1,218 @@ +import { describe, test, expect } from "bun:test"; +import { + createInMemoryMailboxStore, + executeSearch, + fetchHeaders, + fetchStructure, + fetchPart, + type MailboxStore, + type StoredEnvelope, +} from "./index"; + +const encoder = new TextEncoder(); + +function envelopeFor(overrides: Partial = {}): StoredEnvelope { + return { + messageId: "<1@x>", + from: "alice@x", + to: ["bob@y"], + subject: "Hello", + date: new Date("2026-01-01T00:00:00Z"), + inReplyTo: undefined, + references: [], + interchangeType: undefined, + interchangeCorrelationId: undefined, + ...overrides, + }; +} + +/** A minimal single-part RFC 2822 message with the given subject and body. */ +function rawMessage(subject: string, body: string): Uint8Array { + return encoder.encode( + [ + "From: alice@x", + "To: bob@y", + `Subject: ${subject}`, + "Message-ID: <1@x>", + "Date: Thu, 01 Jan 2026 00:00:00 +0000", + "Content-Type: text/plain", + "", + body, + ].join("\r\n"), + ); +} + +/** A two-part multipart/mixed message; part 1 is a text/plain body. */ +function rawMultipart(body: string): Uint8Array { + const boundary = "b0undary"; + return encoder.encode( + [ + "From: alice@x", + "To: bob@y", + "Subject: Multipart", + "Message-ID: <1@x>", + "Date: Thu, 01 Jan 2026 00:00:00 +0000", + `Content-Type: multipart/mixed; boundary="${boundary}"`, + "", + `--${boundary}`, + "Content-Type: text/plain", + "", + body, + `--${boundary}--`, + "", + ].join("\r\n"), + ); +} + +/** + * Wrap a store so every `readRaw` is counted. Proves the pure functions read + * raw only when a projection or predicate needs the bytes. + */ +function countingStore(inner: MailboxStore): { + store: MailboxStore; + readRawCount: () => number; +} { + let count = 0; + const store: MailboxStore = { + get uidValidity() { + return inner.uidValidity; + }, + get uidNext() { + return inner.uidNext; + }, + get highestModSeq() { + return inner.highestModSeq; + }, + get messages() { + return inner.messages; + }, + append: (raw, envelope, flags) => inner.append(raw, envelope, flags), + readRaw: (uid) => { + count++; + return inner.readRaw(uid); + }, + find: (uid) => inner.find(uid), + addFlags: (uid, flags) => inner.addFlags(uid, flags), + removeFlags: (uid, flags) => inner.removeFlags(uid, flags), + remove: (uid) => inner.remove(uid), + }; + return { store, readRawCount: () => count }; +} + +describe("in-memory readRaw", () => { + test("returns the appended bytes and the model carries no raw", async () => { + const store = createInMemoryMailboxStore(); + const raw = rawMessage("Hello", "body text"); + const uid = store.append(raw, envelopeFor(), []); + + expect(await store.readRaw(uid)).toEqual(raw); + // The resident message model never carries the raw bytes. + expect("raw" in (store.find(uid) ?? {})).toBe(false); + }); + + test("throws for an absent uid", async () => { + const store = createInMemoryMailboxStore(); + await expect(store.readRaw(9999)).rejects.toThrow(/not found/); + }); + + test("drops the bytes on remove", async () => { + const store = createInMemoryMailboxStore(); + const uid = store.append(rawMessage("Hello", "b"), envelopeFor(), []); + store.remove(uid); + await expect(store.readRaw(uid)).rejects.toThrow(/not found/); + }); +}); + +describe("executeSearch reads raw only when a predicate needs it", () => { + test("envelope and flag predicates never read raw", async () => { + const { store, readRawCount } = countingStore(createInMemoryMailboxStore()); + store.append(rawMessage("Hello", "body text"), envelopeFor(), ["\\Seen"]); + + const byFrom = await executeSearch("INBOX", store, { from: "alice" }); + const byFlag = await executeSearch("INBOX", store, { + hasFlags: ["\\Seen"], + }); + + expect(byFrom).toHaveLength(1); + expect(byFlag).toHaveLength(1); + expect(readRawCount()).toBe(0); + }); + + test("a header predicate reads raw, memoized once per message", async () => { + const { store, readRawCount } = countingStore(createInMemoryMailboxStore()); + store.append(rawMessage("Hello", "body text"), envelopeFor(), []); + + const bySubject = await executeSearch("INBOX", store, { + header: { field: "Subject", contains: "hello" }, + }); + expect(bySubject).toHaveLength(1); + expect(readRawCount()).toBe(1); + + // Two raw-scanning predicates over one message still read the blob once. + const combined = await executeSearch("INBOX", store, { + and: [ + { header: { field: "Subject", contains: "hello" } }, + { text: "body" }, + ], + }); + expect(combined).toHaveLength(1); + // One additional read for the single candidate, memoized across both subs. + expect(readRawCount()).toBe(2); + }); + + test("a body/text predicate reads raw and matches on content", async () => { + const store = createInMemoryMailboxStore(); + store.append(rawMessage("Hello", "the needle is here"), envelopeFor(), []); + + const hit = await executeSearch("INBOX", store, { text: "needle" }); + const miss = await executeSearch("INBOX", store, { text: "haystack" }); + expect(hit).toHaveLength(1); + expect(miss).toHaveLength(0); + }); +}); + +describe("async fetch projections route through readRaw", () => { + test("fetchHeaders parses the full header set from raw", async () => { + const store = createInMemoryMailboxStore(); + const uid = store.append( + rawMessage("Subject Line", "b"), + envelopeFor(), + [], + ); + + const headers = await fetchHeaders({ uid, mailbox: "INBOX" }, store); + expect(headers.from).toBe("alice@x"); + expect(headers.to).toContain("bob@y"); + expect(headers.subject).toBe("Subject Line"); + }); + + test("fetchStructure describes a single text part", async () => { + const store = createInMemoryMailboxStore(); + const uid = store.append( + rawMessage("Hello", "part body"), + envelopeFor(), + [], + ); + + const structure = await fetchStructure({ uid, mailbox: "INBOX" }, store); + expect(structure.contentType).toBe("text/plain"); + }); + + test("fetchStructure and fetchPart read a multipart body", async () => { + const store = createInMemoryMailboxStore(); + const uid = store.append(rawMultipart("part body"), envelopeFor(), []); + + const structure = await fetchStructure({ uid, mailbox: "INBOX" }, store); + expect(structure.contentType).toContain("multipart/mixed"); + + const part = await fetchPart({ uid, mailbox: "INBOX" }, "1", store); + expect(new TextDecoder().decode(part.content)).toContain("part body"); + }); + + test("fetch projections reject an absent uid", async () => { + const store = createInMemoryMailboxStore(); + await expect( + fetchHeaders({ uid: 42, mailbox: "INBOX" }, store), + ).rejects.toThrow(/not found/); + }); +}); diff --git a/vendor/intx-mailbox/src/fetch.ts b/vendor/intx-mailbox/src/fetch.ts new file mode 100644 index 000000000..792c7d6e9 --- /dev/null +++ b/vendor/intx-mailbox/src/fetch.ts @@ -0,0 +1,250 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME multipart parsing with bounds checks */ +import { type } from "arktype"; +import type { + MessageHeaders, + BodyStructure, + MessagePart, + InboundMessage, + SignatureStatus, + CryptoProvider, + MessageRef, +} from "@intx/types/runtime"; +import { InterchangeType } from "@intx/types/runtime"; +import { base64Decode } from "@intx/types"; +import type { MailboxStore } from "./mailbox"; +import { requireMessage } from "./mailbox"; +import { + parseHeaderSection, + parseMimePart, + extractBoundary, + parseMultipart, + extractPartByPath, + extractAttachments, +} from "@intx/mime"; +import { buildMessageHeaders } from "./headers"; +import { verifyDetachedSignature } from "@intx/crypto"; + +const MessagePayload = type({ + type: InterchangeType, + version: "string", + body: "Record", +}); + +/** + * Parse the full RFC 2822 headers of a stored message. Reads the message's raw + * bytes on demand: the parsed set is a superset of the pre-parsed envelope (it + * carries `cc`, `mimeVersion`, trace headers, ...), so it cannot be served from + * the envelope metadata alone. + */ +export async function fetchHeaders( + ref: MessageRef, + store: MailboxStore, +): Promise { + requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const { headers } = parseHeaderSection(raw); + return buildMessageHeaders(headers); +} + +/** + * Compute the MIME tree structure (BODYSTRUCTURE) without transferring content. + */ +export async function fetchStructure( + ref: MessageRef, + store: MailboxStore, +): Promise { + requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = headers.get("content-type") ?? "application/octet-stream"; + return buildStructure(body, contentType); +} + +/** + * Fetch a single MIME part by dot-separated path. + */ +export async function fetchPart( + ref: MessageRef, + partPath: string, + store: MailboxStore, +): Promise { + requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const partBytes = extractPartByPath(raw, partPath); + const part = parseMimePart(partBytes); + + const enc = part.headers.get("content-transfer-encoding") ?? "7bit"; + let content: Uint8Array; + + if (enc.toLowerCase() === "base64") { + const b64 = new TextDecoder().decode(part.body).replace(/\s/g, ""); + content = base64Decode(b64); + } else { + content = part.body; + } + + const result: MessagePart = { + contentType: part.contentType, + content, + }; + if (enc !== "7bit") result.encoding = enc; + return result; +} + +/** + * Fetch a complete message, verify its PGP/MIME signature, and return + * a fully parsed InboundMessage. + */ +export async function fetchFull( + ref: MessageRef, + store: MailboxStore, + getCrypto: (fromAddress: string) => CryptoProvider | undefined, +): Promise { + const msg = requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const { headers } = parseHeaderSection(raw); + const parsedHeaders = buildMessageHeaders(headers); + + const rawType = parsedHeaders.interchangeType; + const isConversation = + rawType === "conversation.message" || + rawType === "conversation.join" || + rawType === "conversation.leave" || + rawType === undefined; + + const signatureStatus = await verifyMessageSignature( + raw, + parsedHeaders.from, + getCrypto, + ); + + const result: InboundMessage = { + ref, + headers: parsedHeaders, + flags: Array.from(msg.flags), + signatureStatus, + }; + + try { + if (isConversation) { + const part1 = parseMimePart(extractPartByPath(raw, "1")); + const part1Mime = part1.contentType.split(";")[0]!.trim().toLowerCase(); + if (part1Mime.startsWith("multipart/")) { + // Conversation shape: multipart/mixed with the text body at 1.1. + const textPart = parseMimePart(extractPartByPath(raw, "1.1")); + result.content = new TextDecoder("utf-8", { fatal: false }).decode( + textPart.body, + ); + } else { + // A conversation message is "literally a signed email", so a sender + // (e.g. a plain mail client) may sign a bare text/plain part with no + // multipart/mixed wrapper. This branch reads that body directly. Our + // own assembler always emits multipart/mixed; without this branch a + // bare text/plain message would fail the 1.1 lookup and silently lose + // its content to the catch below. + result.content = new TextDecoder("utf-8", { fatal: false }).decode( + part1.body, + ); + } + } else { + // Structured messages carry their JSON payload at 1.1. Attachments on + // structured messages are intentionally not parsed: they have no + // producer today, so parsing them would handle a shape nobody sends. + const part11Bytes = extractPartByPath(raw, "1.1"); + const part11 = parseMimePart(part11Bytes); + const jsonText = new TextDecoder("utf-8", { fatal: false }).decode( + part11.body, + ); + const validated = MessagePayload(JSON.parse(jsonText)); + if (validated instanceof type.errors) { + throw new Error(`invalid message payload: ${validated.summary}`); + } + result.payload = validated; + } + } catch { + // If we can't parse the content, return what we have with the signature status. + } + + // Attachment parsing is deliberately outside the catch above: a malformed + // attachment must surface as a thrown error, not be silently dropped. + if (isConversation) { + const attachments = extractAttachments(raw); + if (attachments.length > 0) { + result.attachments = attachments; + } + } + + return result; +} + +async function verifyMessageSignature( + raw: Uint8Array, + fromAddress: string, + getCrypto: (fromAddress: string) => CryptoProvider | undefined, +): Promise { + const senderCrypto = getCrypto(fromAddress); + if (senderCrypto === undefined) { + return "unknown"; + } + + try { + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = headers.get("content-type") ?? ""; + + if (!contentType.toLowerCase().includes("multipart/signed")) { + return "missing"; + } + + const boundary = extractBoundary(contentType); + if (boundary === undefined) return "missing"; + + const parts = parseMultipart(body, boundary); + if (parts.length < 2) return "missing"; + + const signedContentBytes = parts[0]!; + const sigPartBytes = parts[1]!; + const sigPart = parseMimePart(sigPartBytes); + + if ( + !sigPart.contentType.toLowerCase().includes("application/pgp-signature") + ) { + return "missing"; + } + + const publicKey = senderCrypto.getPublicKey(); + const valid = await verifyDetachedSignature( + signedContentBytes, + sigPart.body, + publicKey, + ); + + return valid ? "valid" : "invalid"; + } catch { + return "invalid"; + } +} + +function buildStructure(body: Uint8Array, contentType: string): BodyStructure { + const ct = contentType.toLowerCase(); + if (!ct.startsWith("multipart/")) { + return { contentType, size: body.length }; + } + + const boundary = extractBoundary(contentType); + if (boundary === undefined) { + return { contentType, size: body.length }; + } + + const parts = parseMultipart(body, boundary); + const subStructures: BodyStructure[] = parts.map((partBytes) => { + const { headers, bodyOffset } = parseHeaderSection(partBytes); + const partBody = partBytes.slice(bodyOffset); + const partContentType = + headers.get("content-type") ?? "application/octet-stream"; + return buildStructure(partBody, partContentType); + }); + + return { contentType, parts: subStructures }; +} diff --git a/vendor/intx-mailbox/src/headers.ts b/vendor/intx-mailbox/src/headers.ts new file mode 100644 index 000000000..0e4021126 --- /dev/null +++ b/vendor/intx-mailbox/src/headers.ts @@ -0,0 +1,5 @@ +// `buildMessageHeaders` now lives in `@intx/mime` alongside the rest of the +// MIME/header parsing (it is also what the `decodeMail` decoder builds its +// typed header subset with). Re-exported here so mail-memory's callers keep +// their existing import path. +export { buildMessageHeaders } from "@intx/mime"; diff --git a/vendor/intx-mailbox/src/index.ts b/vendor/intx-mailbox/src/index.ts new file mode 100644 index 000000000..efc103b74 --- /dev/null +++ b/vendor/intx-mailbox/src/index.ts @@ -0,0 +1,11 @@ +export { + DEFAULT_MAILBOXES, + createInMemoryMailboxStore, + requireMessage, +} from "./mailbox"; +export type { MailboxStore, StoredMessage, StoredEnvelope } from "./mailbox"; + +export { executeSearch } from "./search"; +export { executeThread } from "./thread"; +export { fetchHeaders, fetchStructure, fetchPart, fetchFull } from "./fetch"; +export { buildMessageHeaders } from "./headers"; diff --git a/vendor/intx-mailbox/src/mailbox.ts b/vendor/intx-mailbox/src/mailbox.ts new file mode 100644 index 000000000..05a3e9455 --- /dev/null +++ b/vendor/intx-mailbox/src/mailbox.ts @@ -0,0 +1,192 @@ +/** + * Pre-parsed envelope extracted from MIME headers at delivery time. + * Avoids re-parsing raw bytes for every search operation. + */ +export type StoredEnvelope = { + messageId: string; + from: string; + to: string[]; + subject: string; + date: Date; + inReplyTo: string | undefined; + references: string[]; + interchangeType: string | undefined; + interchangeCorrelationId: string | undefined; +}; + +/** + * A single stored message's resident model: its uid, the IMAP counters, its + * flags, and the pre-parsed envelope. The complete RFC 2822 bytes are NOT + * resident here; they are read on demand through `MailboxStore.readRaw`, so a + * backing can bound its in-memory footprint to metadata and keep the raw bytes + * on disk (the substrate backing) or retain them itself (the in-memory + * backing). The projections that need the bytes -- `fetchFull`, `fetchPart`, + * `fetchStructure`, `fetchHeaders`, and the raw-scanning search predicates -- + * route through `readRaw`, which returns the verbatim bytes so signature + * verification stays byte-exact. + */ +export type StoredMessage = { + uid: number; + modseq: number; + flags: Set; + envelope: StoredEnvelope; +}; + +/** + * Storage-agnostic per-mailbox model. A backing owns how the message list and + * the uid/modseq/uidValidity counters are stored; the pure query and + * projection functions (search, thread, fetch, bodystructure, headers) read + * the message snapshot the backing exposes through `messages`, and read a + * message's raw bytes on demand through `readRaw`. + * + * The counters follow IMAP semantics: `uidNext` is the UID that the next + * `append` will assign (UIDNEXT), `highestModSeq` is the largest MODSEQ + * currently assigned (HIGHESTMODSEQ), and `uidValidity` is stable for the + * lifetime of the mailbox (UIDVALIDITY). + */ +export interface MailboxStore { + readonly uidValidity: number; + readonly uidNext: number; + readonly highestModSeq: number; + readonly messages: readonly StoredMessage[]; + + /** + * Store a message, assigning it the next UID and MODSEQ. Returns the + * assigned UID. The backing decides whether to retain `raw` in memory or + * persist it and serve it from disk through `readRaw`. + */ + append(raw: Uint8Array, envelope: StoredEnvelope, flags: string[]): number; + + /** + * Read a stored message's verbatim RFC 2822 bytes. Resolves the bytes from + * wherever the backing keeps them (memory or disk). Throws if no message has + * the given UID. + */ + readRaw(uid: number): Promise; + + /** Locate a stored message by UID, or `undefined` if none matches. */ + find(uid: number): StoredMessage | undefined; + + /** + * Add flags to a stored message and advance its MODSEQ. Returns the updated + * message. Throws if no message has the given UID. + */ + addFlags(uid: number, flags: string[]): StoredMessage; + + /** + * Remove flags from a stored message and advance its MODSEQ. Returns the + * updated message. Throws if no message has the given UID. + */ + removeFlags(uid: number, flags: string[]): StoredMessage; + + /** Drop a stored message by UID. Throws if no message has the given UID. */ + remove(uid: number): void; +} + +/** + * The default set of mailboxes created for a freshly registered address. + */ +export const DEFAULT_MAILBOXES = [ + "INBOX", + "Sent", + "Drafts", + "Archive", + "Trash", +] as const; + +/** + * Create an in-memory `MailboxStore` backing. Messages, counters, and + * uidValidity live in process memory for the lifetime of the returned store. + */ +export function createInMemoryMailboxStore(): MailboxStore { + const messages: StoredMessage[] = []; + // The in-memory backing is its own durable store, so it legitimately retains + // every message's raw bytes. `readRaw` returns them; the metadata mirror in + // `messages` stays free of the bytes so the read model matches the + // disk-backed backing. + const rawByUid = new Map(); + let uidCounter = 1; + let modseqCounter = 1; + const uidValidity = Date.now(); + + function find(uid: number): StoredMessage | undefined { + return messages.find((m) => m.uid === uid); + } + + function require(uid: number): StoredMessage { + const msg = find(uid); + if (msg === undefined) { + throw new Error(`Message UID ${uid} not found`); + } + return msg; + } + + return { + uidValidity, + get uidNext() { + return uidCounter; + }, + get highestModSeq() { + return modseqCounter - 1; + }, + get messages() { + return messages; + }, + append(raw, envelope, flags) { + const uid = uidCounter++; + const modseq = modseqCounter++; + messages.push({ uid, modseq, flags: new Set(flags), envelope }); + rawByUid.set(uid, raw); + return uid; + }, + readRaw(uid) { + const raw = rawByUid.get(uid); + if (raw === undefined) { + return Promise.reject(new Error(`Message UID ${uid} not found`)); + } + return Promise.resolve(raw); + }, + find, + addFlags(uid, flags) { + const msg = require(uid); + for (const flag of flags) { + msg.flags.add(flag); + } + msg.modseq = modseqCounter++; + return msg; + }, + removeFlags(uid, flags) { + const msg = require(uid); + for (const flag of flags) { + msg.flags.delete(flag); + } + msg.modseq = modseqCounter++; + return msg; + }, + remove(uid) { + const idx = messages.findIndex((m) => m.uid === uid); + if (idx === -1) { + throw new Error(`Message UID ${uid} not found`); + } + messages.splice(idx, 1); + rawByUid.delete(uid); + }, + }; +} + +/** + * Locate a stored message by UID, throwing a mailbox-qualified error when it + * is absent. Used by the fetch projections, which resolve a `MessageRef` + * against a specific mailbox. + */ +export function requireMessage( + store: MailboxStore, + uid: number, + mailboxName: string, +): StoredMessage { + const msg = store.find(uid); + if (msg === undefined) { + throw new Error(`Message UID ${uid} not found in mailbox "${mailboxName}"`); + } + return msg; +} diff --git a/vendor/intx-mailbox/src/search.ts b/vendor/intx-mailbox/src/search.ts new file mode 100644 index 000000000..4efc54c2e --- /dev/null +++ b/vendor/intx-mailbox/src/search.ts @@ -0,0 +1,208 @@ +import type { SearchQuery, MessageRef } from "@intx/types/runtime"; +import type { MailboxStore, StoredMessage } from "./mailbox"; +import { parseHeaderSection } from "@intx/mime"; + +/** + * Execute an IMAP SEARCH-equivalent query over a mailbox. + * + * Supports: from, to, cc, bcc, header (field match), before/after/on, + * sentBefore/sentAfter/sentOn, hasFlags, missingFlags, body, text, + * largerThan, smallerThan, and boolean and/or/not composition. + * + * The envelope- and flag-based predicates (from, to, dates, flags, boolean + * composition) resolve from metadata alone. The predicates that inspect + * headers the envelope does not carry (cc, bcc, arbitrary `header`), the body, + * or the raw size (body, text, largerThan, smallerThan) read a message's raw + * bytes on demand through `store.readRaw`, memoized per message so a query that + * touches raw reads each candidate's blob at most once. A query with no + * raw-scanning predicate never reads a blob. + * + * Returns MessageRef[] for all matching messages, ordered by UID. + */ +export async function executeSearch( + mailboxName: string, + store: MailboxStore, + query: SearchQuery, +): Promise { + const results: MessageRef[] = []; + for (const msg of store.messages) { + if (await matchMessage(msg, query, makeRawReader(store, msg.uid))) { + results.push({ uid: msg.uid, mailbox: mailboxName }); + } + } + return results; +} + +/** + * A per-message memoized reader for the raw bytes. The first raw-scanning + * predicate reads the blob through `store.readRaw`; every later predicate on + * the same message reuses the resolved bytes. + */ +function makeRawReader( + store: MailboxStore, + uid: number, +): () => Promise { + let pending: Promise | undefined; + return () => { + if (pending === undefined) pending = store.readRaw(uid); + return pending; + }; +} + +async function matchMessage( + msg: StoredMessage, + query: SearchQuery, + readRaw: () => Promise, +): Promise { + if (query.from !== undefined) { + if (!msg.envelope.from.toLowerCase().includes(query.from.toLowerCase())) { + return false; + } + } + + if (query.to !== undefined) { + const queryTo = query.to; + const toMatch = msg.envelope.to.some((addr) => + addr.toLowerCase().includes(queryTo.toLowerCase()), + ); + if (!toMatch) return false; + } + + if (query.cc !== undefined) { + const headers = await lazyHeaders(msg, readRaw); + const ccHeader = headers.get("cc") ?? ""; + if (!ccHeader.toLowerCase().includes(query.cc.toLowerCase())) { + return false; + } + } + + if (query.bcc !== undefined) { + const headers = await lazyHeaders(msg, readRaw); + const bccHeader = headers.get("bcc") ?? ""; + if (!bccHeader.toLowerCase().includes(query.bcc.toLowerCase())) { + return false; + } + } + + if (query.header !== undefined) { + const { field, contains } = query.header; + const headers = await lazyHeaders(msg, readRaw); + const value = headers.get(field.toLowerCase()) ?? ""; + if (!value.toLowerCase().includes(contains.toLowerCase())) { + return false; + } + } + + if (query.before !== undefined) { + if (msg.envelope.date >= query.before) return false; + } + if (query.after !== undefined) { + if (msg.envelope.date <= query.after) return false; + } + if (query.on !== undefined) { + const d = msg.envelope.date; + const q = query.on; + if ( + d.getUTCFullYear() !== q.getUTCFullYear() || + d.getUTCMonth() !== q.getUTCMonth() || + d.getUTCDate() !== q.getUTCDate() + ) { + return false; + } + } + + // Sent date filters use the Date header (same as envelope date here). + if (query.sentBefore !== undefined) { + if (msg.envelope.date >= query.sentBefore) return false; + } + if (query.sentAfter !== undefined) { + if (msg.envelope.date <= query.sentAfter) return false; + } + if (query.sentOn !== undefined) { + const d = msg.envelope.date; + const q = query.sentOn; + if ( + d.getUTCFullYear() !== q.getUTCFullYear() || + d.getUTCMonth() !== q.getUTCMonth() || + d.getUTCDate() !== q.getUTCDate() + ) { + return false; + } + } + + if (query.hasFlags !== undefined) { + for (const flag of query.hasFlags) { + if (!msg.flags.has(flag)) return false; + } + } + + if (query.missingFlags !== undefined) { + for (const flag of query.missingFlags) { + if (msg.flags.has(flag)) return false; + } + } + + if (query.largerThan !== undefined) { + if ((await readRaw()).length <= query.largerThan) return false; + } + if (query.smallerThan !== undefined) { + if ((await readRaw()).length >= query.smallerThan) return false; + } + + if (query.body !== undefined || query.text !== undefined) { + const raw = await readRaw(); + const rawText = new TextDecoder("utf-8", { fatal: false }).decode(raw); + if (query.body !== undefined) { + const { bodyOffset } = parseHeaderSection(raw); + const bodyText = new TextDecoder("utf-8", { fatal: false }).decode( + raw.slice(bodyOffset), + ); + if (!bodyText.toLowerCase().includes(query.body.toLowerCase())) { + return false; + } + } + if (query.text !== undefined) { + if (!rawText.toLowerCase().includes(query.text.toLowerCase())) { + return false; + } + } + } + + if (query.and !== undefined) { + for (const sub of query.and) { + if (!(await matchMessage(msg, sub, readRaw))) return false; + } + } + + if (query.or !== undefined) { + if (query.or.length > 0) { + let anyMatch = false; + for (const sub of query.or) { + if (await matchMessage(msg, sub, readRaw)) { + anyMatch = true; + break; + } + } + if (!anyMatch) return false; + } + } + + if (query.not !== undefined) { + if (await matchMessage(msg, query.not, readRaw)) return false; + } + + return true; +} + +const headerCache = new WeakMap>(); + +async function lazyHeaders( + msg: StoredMessage, + readRaw: () => Promise, +): Promise> { + const cached = headerCache.get(msg); + if (cached !== undefined) return cached; + const { headers } = parseHeaderSection(await readRaw()); + headerCache.set(msg, headers); + return headers; +} diff --git a/vendor/intx-mailbox/src/thread.ts b/vendor/intx-mailbox/src/thread.ts new file mode 100644 index 000000000..024557901 --- /dev/null +++ b/vendor/intx-mailbox/src/thread.ts @@ -0,0 +1,275 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Map.get()! after has() checks in threading algorithm */ +import type { Thread, SearchQuery } from "@intx/types/runtime"; +import type { MailboxStore, StoredMessage } from "./mailbox"; +import { executeSearch } from "./search"; + +/** + * RFC 5256 REFERENCES threading algorithm. + * + * Builds parent-child relationships from In-Reply-To and References headers. + * The algorithm: + * 1. For each message, collect its References chain (oldest → newest ancestor). + * 2. Link messages into a tree using these chains. + * 3. Create dummy containers for referenced messages not present in the set. + * 4. Prune dummy containers with no children; promote children of childless dummies. + * 5. Gather root-level containers with the same base subject (skipped here — + * we implement only the parent/child linking portion which is what this + * transport needs; subject-based gathering is optional for our use case). + * 6. Sort threads at each level. + * + * Note: RFC 5256 also defines an ORDEREDSUBJECT algorithm. For that, messages + * are sorted by subject and date without reference tracking. + */ + +type Container = { + messageId: string; + message: StoredMessage | null; + parent: Container | null; + children: Container[]; +}; + +export async function executeThread( + mailboxName: string, + store: MailboxStore, + algorithm: "references" | "orderedsubject", + query?: SearchQuery, +): Promise { + let messages: StoredMessage[]; + + if (query !== undefined) { + const refs = await executeSearch(mailboxName, store, query); + const uidSet = new Set(refs.map((r) => r.uid)); + messages = store.messages.filter((m) => uidSet.has(m.uid)); + } else { + messages = [...store.messages]; + } + + if (messages.length === 0) return []; + + if (algorithm === "orderedsubject") { + return orderedSubjectThread(mailboxName, messages); + } + + return referencesThread(mailboxName, messages); +} + +/** + * RFC 5256 ORDEREDSUBJECT: sort by base subject, then date. + * All messages with the same base subject form one thread; the first by date + * is the root, the rest are direct children. + */ +function orderedSubjectThread( + mailboxName: string, + messages: StoredMessage[], +): Thread[] { + const bySubject = new Map(); + + for (const msg of messages) { + const base = baseSubject(msg.envelope.subject); + const bucket = bySubject.get(base); + if (bucket === undefined) { + bySubject.set(base, [msg]); + } else { + bucket.push(msg); + } + } + + const threads: Thread[] = []; + for (const [, msgs] of bySubject) { + const sorted = msgs.sort( + (a, b) => a.envelope.date.getTime() - b.envelope.date.getTime(), + ); + const root = sorted[0]!; + const rootThread: Thread = { + ref: { uid: root.uid, mailbox: mailboxName }, + children: sorted.slice(1).map((m) => ({ + ref: { uid: m.uid, mailbox: mailboxName }, + children: [], + })), + }; + threads.push(rootThread); + } + + return threads.sort((a, b) => { + const aMsg = messages.find((m) => m.uid === a.ref.uid)!; + const bMsg = messages.find((m) => m.uid === b.ref.uid)!; + return aMsg.envelope.date.getTime() - bMsg.envelope.date.getTime(); + }); +} + +/** + * RFC 5256 REFERENCES algorithm. + * + * Step 1: For each message, create a container. Walk its References list + * (and In-Reply-To if not already in References) and link containers + * as parent-child in left-to-right order. + * + * Step 2: Build the id_table mapping Message-IDs to containers. + * + * Step 3: Prune empty containers (those with no message). + * + * Step 4: Collect root containers. + * + * Step 5: Sort each container's children by date. + */ +function referencesThread( + mailboxName: string, + messages: StoredMessage[], +): Thread[] { + const idTable = new Map(); + + function getOrCreate(msgId: string): Container { + const existing = idTable.get(msgId); + if (existing !== undefined) return existing; + const c: Container = { + messageId: msgId, + message: null, + parent: null, + children: [], + }; + idTable.set(msgId, c); + return c; + } + + // Step 1 & 2: Build containers and link parent-child relationships. + for (const msg of messages) { + const container = getOrCreate(msg.envelope.messageId); + container.message = msg; + + // Build the reference list: References + In-Reply-To (deduplicated). + const refs = buildRefList(msg.envelope.references, msg.envelope.inReplyTo); + + // Link: refs[i] is parent of refs[i+1], last ref is parent of this message. + let prevContainer: Container | null = null; + for (const refId of refs) { + const refContainer = getOrCreate(refId); + + if ( + prevContainer !== null && + refContainer.parent === null && + !isAncestor(refContainer, prevContainer) + ) { + prevContainer.children.push(refContainer); + refContainer.parent = prevContainer; + } + + prevContainer = refContainer; + } + + // Link the last reference as parent of this message (if no circular reference). + if ( + prevContainer !== null && + container.parent === null && + !isAncestor(container, prevContainer) + ) { + prevContainer.children.push(container); + container.parent = prevContainer; + } + } + + // Step 3: Find root containers (no parent). + const roots: Container[] = []; + for (const [, c] of idTable) { + if (c.parent === null) { + roots.push(c); + } + } + + // Step 4: Prune dummy containers (containers with no message). + // A dummy with no children is dropped. + // A dummy with children: the children are promoted to the dummy's parent level. + const prunedRoots = pruneContainers(roots); + + // Step 5: Sort and convert to Thread[]. + return containersToThreads(mailboxName, prunedRoots); +} + +function buildRefList(references: string[], inReplyTo?: string): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const ref of references) { + if (ref && !seen.has(ref)) { + seen.add(ref); + result.push(ref); + } + } + + if (inReplyTo !== undefined && inReplyTo !== "" && !seen.has(inReplyTo)) { + result.push(inReplyTo); + } + + return result; +} + +function isAncestor(potentialAncestor: Container, of: Container): boolean { + let cur: Container | null = of; + while (cur !== null) { + if (cur === potentialAncestor) return true; + cur = cur.parent; + } + return false; +} + +function pruneContainers(containers: Container[]): Container[] { + const result: Container[] = []; + for (const c of containers) { + if (c.message === null && c.children.length === 0) { + // Dummy with no children: drop it. + continue; + } + if (c.message === null && c.children.length > 0) { + // Dummy with children: promote children (skip the dummy). + const promotedChildren = pruneContainers(c.children); + result.push(...promotedChildren); + } else { + // Real message: recurse into children. + c.children = pruneContainers(c.children); + result.push(c); + } + } + return result; +} + +function containerDate(c: Container): number { + if (c.message !== null) { + return c.message.envelope.date.getTime(); + } + // For dummy containers, use the earliest child date. + let earliest = Infinity; + for (const child of c.children) { + const d = containerDate(child); + if (d < earliest) earliest = d; + } + return earliest === Infinity ? 0 : earliest; +} + +function containersToThreads( + mailboxName: string, + containers: Container[], +): Thread[] { + // Sort by date of the container (or earliest descendant for dummies). + const sorted = containers.sort((a, b) => containerDate(a) - containerDate(b)); + + return sorted + .filter((c) => c.message !== null) + .map((c) => ({ + ref: { uid: c.message!.uid, mailbox: mailboxName }, + children: containersToThreads(mailboxName, c.children), + })); +} + +function baseSubject(subject: string): string { + // Strip "Re:", "Fwd:", "Fw:" prefixes (case-insensitive) repeatedly. + let s = subject.trim(); + let changed = true; + while (changed) { + changed = false; + const m = s.match(/^(?:re|fwd?)\s*:\s*/i); + if (m !== null) { + s = s.slice(m[0].length).trim(); + changed = true; + } + } + return s; +} diff --git a/vendor/intx-mailbox/tsconfig.json b/vendor/intx-mailbox/tsconfig.json new file mode 100644 index 000000000..9e25e6ece --- /dev/null +++ b/vendor/intx-mailbox/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/vendor/intx-mime/LICENSE b/vendor/intx-mime/LICENSE new file mode 100644 index 000000000..c6487f4fd --- /dev/null +++ b/vendor/intx-mime/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/vendor/intx-mime/README.md b/vendor/intx-mime/README.md new file mode 100644 index 000000000..a8124247f --- /dev/null +++ b/vendor/intx-mime/README.md @@ -0,0 +1,32 @@ +# @intx/mime + +RFC 2822 message assembly and parsing. Builds multipart/signed +messages with PGP detached signatures, parses inbound wire bytes +back into structured parts, and owns the JMAP-shaped envelope the +rest of the mail pipeline depends on. + +Consumed by `@intx/mail-memory` (in-process transport), +`@intx/storage-isogit` (mail audit log), and `@intx/harness` +(sidecar mail-tool plumbing). + +```ts +import { + parseHeaderSection, + parseMultipart, + extractBoundary, +} from "@intx/mime"; + +const { headers, bodyOffset } = parseHeaderSection(rawMessageBytes); +const contentType = headers.get("content-type"); +if (contentType === undefined) throw new Error("missing Content-Type"); +const boundary = extractBoundary(contentType); +if (boundary === undefined) throw new Error("missing boundary parameter"); +const parts = parseMultipart(rawMessageBytes.subarray(bodyOffset), boundary); +``` + +For outbound mail the builder layer is the entry point: +`createOutboundMessage` produces a structured envelope, +`assembleSignedContent` canonicalises the signed body, and +`assembleMessage` joins the signed content with a detached +signature from `createDetachedSignatureFromProvider` into wire +bytes. diff --git a/vendor/intx-mime/package.json b/vendor/intx-mime/package.json new file mode 100644 index 000000000..db906cc64 --- /dev/null +++ b/vendor/intx-mime/package.json @@ -0,0 +1,17 @@ +{ + "name": "@intx/mime", + "version": "0.2.2", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/types": "workspace:*", + "arktype": "catalog:" + } +} diff --git a/vendor/intx-mime/src/index.test.ts b/vendor/intx-mime/src/index.test.ts new file mode 100644 index 000000000..874f69883 --- /dev/null +++ b/vendor/intx-mime/src/index.test.ts @@ -0,0 +1,1147 @@ +import { describe, test, expect } from "bun:test"; +import { + generateKeyPair, + createEd25519Crypto, + verifyDetachedSignature, +} from "@intx/crypto"; +import { + assembleSignedContent, + assembleMessage, + createDetachedSignatureFromProvider, + extractAddrSpec, + formatRFC2822Date, + generateMessageId, + parseHeaderSection, + parseMimePart, + parseMultipart, + extractBoundary, + extractPartByPath, + parseMailToEmail, + extractAttachments, + type MessageHeaders, +} from "./index"; +import type { MessageAttachment } from "@intx/types/runtime"; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function defined(value: T | undefined | null): T { + if (value === undefined || value === null) { + throw new Error("Expected a defined value but got undefined/null"); + } + return value; +} + +function makeHeaders(overrides?: Partial): MessageHeaders { + return { + from: "alice@test.interchange", + to: ["bob@test.interchange"], + cc: undefined, + date: new Date("2026-04-21T12:00:00Z"), + messageId: "", + subject: undefined, + inReplyTo: undefined, + references: undefined, + mimeVersion: "1.0", + interchangeType: undefined, + interchangeCorrelationId: undefined, + interchangeTenantId: undefined, + interchangeAgentId: undefined, + interchangeSessionId: undefined, + interchangeOfferingId: undefined, + interchangeSchemaVersion: undefined, + traceparent: undefined, + tracestate: undefined, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// generateMessageId +// --------------------------------------------------------------------------- + +describe("generateMessageId", () => { + test("extracts domain from address", () => { + const id = generateMessageId("alice@example.com"); + expect(id).toMatch(/^<[0-9a-f-]+@example\.com>$/); + }); + + test("uses local when address has no domain", () => { + const id = generateMessageId("alice"); + expect(id).toMatch(/^<[0-9a-f-]+@local>$/); + }); + + test("produces unique IDs", () => { + const a = generateMessageId("x@y"); + const b = generateMessageId("x@y"); + expect(a).not.toBe(b); + }); +}); + +// --------------------------------------------------------------------------- +// formatRFC2822Date +// --------------------------------------------------------------------------- + +describe("extractAddrSpec", () => { + test("strips quoted display name and angle brackets", () => { + expect(extractAddrSpec('"Alice Doe" ')).toBe( + "alice@example.com", + ); + }); + + test("strips unquoted display name and angle brackets", () => { + expect(extractAddrSpec("Alice Doe ")).toBe( + "alice@example.com", + ); + }); + + test("strips bare angle brackets", () => { + expect(extractAddrSpec("")).toBe("alice@example.com"); + }); + + test("passes through a bare addr-spec", () => { + expect(extractAddrSpec("alice@example.com")).toBe("alice@example.com"); + }); + + test("lowercases local-part and domain", () => { + expect(extractAddrSpec("Alice@Example.COM")).toBe("alice@example.com"); + }); + + test("trims surrounding whitespace", () => { + expect(extractAddrSpec(" Alice@Example.com ")).toBe( + "alice@example.com", + ); + }); + + test("throws on empty input", () => { + expect(() => extractAddrSpec(" ")).toThrow(); + }); + + test("throws on input with no '@'", () => { + expect(() => extractAddrSpec("Alice Doe")).toThrow(); + }); + + test("throws on missing local-part", () => { + expect(() => extractAddrSpec("<@example.com>")).toThrow(); + }); + + test("throws on missing domain", () => { + expect(() => extractAddrSpec("")).toThrow(); + }); + + test("throws on trailing content after the closing '>'", () => { + expect(() => + extractAddrSpec("Alice (comment)"), + ).toThrow(); + }); + + test("throws on a quoted local-part", () => { + expect(() => extractAddrSpec('"a@b"@example.com')).toThrow(); + }); + + test("throws on multiple '@' in an unquoted form", () => { + expect(() => extractAddrSpec("a@b@example.com")).toThrow(); + }); +}); + +describe("formatRFC2822Date", () => { + test("formats a known date correctly", () => { + const date = new Date("2026-04-21T14:30:05Z"); + expect(formatRFC2822Date(date)).toBe("Tue, 21 Apr 2026 14:30:05 +0000"); + }); + + test("zero-pads single-digit day and time components", () => { + const date = new Date("2026-01-05T03:04:09Z"); + expect(formatRFC2822Date(date)).toBe("Mon, 05 Jan 2026 03:04:09 +0000"); + }); +}); + +// --------------------------------------------------------------------------- +// assembleSignedContent — conversation +// --------------------------------------------------------------------------- + +describe("assembleSignedContent", () => { + test("conversation wraps text/plain in multipart/mixed with CRLF", () => { + const bytes = assembleSignedContent({ + kind: "conversation", + text: "Hello\nWorld", + }); + const text = dec.decode(bytes); + expect(text).toContain("Content-Type: multipart/mixed;"); + expect(text).toContain("Content-Type: text/plain; charset=utf-8\r\n"); + expect(text).toContain("Content-Transfer-Encoding: 7bit\r\n"); + expect(text).toContain("\r\nHello\r\nWorld"); + }); + + test("conversation strips trailing whitespace but preserves leading", () => { + const bytes = assembleSignedContent({ + kind: "conversation", + text: " leading \nindented ", + }); + const text = dec.decode(bytes); + expect(text).toContain("\r\n leading\r\nindented"); + }); + + test("conversation with empty text produces an empty text part", () => { + const bytes = assembleSignedContent({ + kind: "conversation", + text: "", + }); + const text = dec.decode(bytes); + expect(text).toContain("Content-Type: multipart/mixed;"); + // text/plain part headers, blank line, empty body CRLF, then a boundary + expect(text).toMatch( + /Content-Type: text\/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\n\r\n--/, + ); + }); + + test("conversation normalizes CRLF input without doubling", () => { + const bytes = assembleSignedContent({ + kind: "conversation", + text: "line1\r\nline2", + }); + const text = dec.decode(bytes); + expect(text).toContain("line1\r\nline2"); + expect(text).not.toContain("line1\r\n\r\nline2"); + }); + + test("structured produces multipart/mixed with JSON part", () => { + const bytes = assembleSignedContent({ + kind: "structured", + json: { action: "deploy" }, + }); + const text = dec.decode(bytes); + expect(text).toContain("Content-Type: multipart/mixed;"); + expect(text).toContain( + "Content-Type: application/vnd.interchange+json; charset=utf-8", + ); + expect(text).toContain('{"action":"deploy"}'); + }); + + test("structured without summary produces only the JSON part", () => { + const bytes = assembleSignedContent({ + kind: "structured", + json: { x: 1 }, + }); + const text = dec.decode(bytes); + expect(text).toContain('{"x":1}'); + expect(text).not.toContain("Content-Type: text/plain"); + }); + + test("structured includes optional summary as text/plain part", () => { + const bytes = assembleSignedContent({ + kind: "structured", + json: { x: 1 }, + summary: "A summary", + }); + const text = dec.decode(bytes); + const plainMatches = text.match( + /Content-Type: text\/plain; charset=utf-8/g, + ); + expect(plainMatches).toHaveLength(1); + expect(text).toContain("A summary"); + }); +}); + +// --------------------------------------------------------------------------- +// assembleMessage +// --------------------------------------------------------------------------- + +describe("assembleMessage", () => { + test("produces multipart/signed with correct headers", () => { + const content = assembleSignedContent({ + kind: "conversation", + text: "test", + }); + const fakeSig = enc.encode("FAKE-SIGNATURE"); + const msg = assembleMessage(makeHeaders(), content, fakeSig); + const text = dec.decode(msg); + + expect(text).toContain("From: alice@test.interchange\r\n"); + expect(text).toContain("To: bob@test.interchange\r\n"); + expect(text).toContain("Message-ID: \r\n"); + expect(text).toContain("MIME-Version: 1.0\r\n"); + expect(text).toContain( + 'multipart/signed; protocol="application/pgp-signature"', + ); + expect(text).toContain("micalg=pgp-sha512"); + }); + + test("includes optional headers when provided", () => { + const content = assembleSignedContent({ + kind: "conversation", + text: "test", + }); + const headers = makeHeaders({ + subject: "Test Subject", + cc: ["charlie@test.interchange"], + inReplyTo: "", + references: ["", ""], + interchangeType: "conversation.message", + interchangeSessionId: "sess-123", + }); + const msg = assembleMessage(headers, content, enc.encode("SIG")); + const text = dec.decode(msg); + + expect(text).toContain("Subject: Test Subject\r\n"); + expect(text).toContain("Cc: charlie@test.interchange\r\n"); + expect(text).toContain("In-Reply-To: \r\n"); + expect(text).toContain( + "References: \r\n", + ); + expect(text).toContain("Interchange-Type: conversation.message\r\n"); + expect(text).toContain("Interchange-Session-ID: sess-123\r\n"); + }); + + test("body has exactly two multipart/signed parts", () => { + const content = assembleSignedContent({ + kind: "conversation", + text: "test", + }); + const msg = assembleMessage(makeHeaders(), content, enc.encode("FAKE-SIG")); + const { headers, bodyOffset } = parseHeaderSection(msg); + const ct = defined(headers.get("content-type")); + const boundary = defined(extractBoundary(ct)); + const body = msg.slice(bodyOffset); + const parts = parseMultipart(body, boundary); + expect(parts).toHaveLength(2); + + const sigPart = parseMimePart(defined(parts[1])); + expect(sigPart.contentType).toBe("application/pgp-signature"); + }); +}); + +// --------------------------------------------------------------------------- +// parseHeaderSection +// --------------------------------------------------------------------------- + +describe("parseHeaderSection", () => { + test("parses CRLF-terminated headers", () => { + const raw = enc.encode("From: alice@test\r\nTo: bob@test\r\n\r\nBody here"); + const { headers, bodyOffset } = parseHeaderSection(raw); + expect(headers.get("from")).toBe("alice@test"); + expect(headers.get("to")).toBe("bob@test"); + expect(dec.decode(raw.slice(bodyOffset))).toBe("Body here"); + }); + + test("parses LF-terminated headers", () => { + const raw = enc.encode("From: alice@test\nTo: bob@test\n\nBody"); + const { headers, bodyOffset } = parseHeaderSection(raw); + expect(headers.get("from")).toBe("alice@test"); + expect(dec.decode(raw.slice(bodyOffset))).toBe("Body"); + }); + + test("unfolds continuation lines", () => { + const raw = enc.encode("References: \r\n \r\n\r\nBody"); + const { headers } = parseHeaderSection(raw); + expect(headers.get("references")).toBe(" "); + }); + + test("keeps first value for repeated headers", () => { + const raw = enc.encode("Received: first\r\nReceived: second\r\n\r\nBody"); + const { headers } = parseHeaderSection(raw); + expect(headers.get("received")).toBe("first"); + }); + + test("lowercases header names", () => { + const raw = enc.encode("Content-Type: text/plain\r\n\r\n"); + const { headers } = parseHeaderSection(raw); + expect(headers.has("content-type")).toBe(true); + expect(headers.has("Content-Type")).toBe(false); + }); + + test("bodyOffset is byte-accurate with 2-byte UTF-8 characters", () => { + const raw = enc.encode("Subject: héllo\r\n\r\nBody here"); + const { bodyOffset } = parseHeaderSection(raw); + expect(dec.decode(raw.slice(bodyOffset))).toBe("Body here"); + }); + + test("bodyOffset is byte-accurate with 3-byte UTF-8 characters", () => { + const raw = enc.encode("Subject: \u20ACuro\r\n\r\nBody here"); + const { bodyOffset } = parseHeaderSection(raw); + expect(dec.decode(raw.slice(bodyOffset))).toBe("Body here"); + }); + + test("bodyOffset is byte-accurate with 4-byte UTF-8 characters", () => { + const raw = enc.encode("Subject: \u{1F600}face\r\n\r\nBody here"); + const { bodyOffset } = parseHeaderSection(raw); + expect(dec.decode(raw.slice(bodyOffset))).toBe("Body here"); + }); + + test("no separator treats entire input as headers", () => { + const raw = enc.encode("From: alice\r\nTo: bob"); + const { headers, bodyOffset } = parseHeaderSection(raw); + expect(bodyOffset).toBe(raw.length); + expect(dec.decode(raw.slice(bodyOffset))).toBe(""); + expect(headers.get("from")).toBe("alice"); + }); + + test("empty input returns empty headers and zero offset", () => { + const raw = enc.encode(""); + const { headers, bodyOffset } = parseHeaderSection(raw); + expect(bodyOffset).toBe(0); + expect(headers.size).toBe(0); + }); + + test("separator-only input returns empty headers", () => { + const raw = enc.encode("\r\n\r\n"); + const { headers, bodyOffset } = parseHeaderSection(raw); + expect(bodyOffset).toBe(4); + expect(headers.size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// extractBoundary +// --------------------------------------------------------------------------- + +describe("extractBoundary", () => { + test("extracts quoted boundary", () => { + const ct = 'multipart/signed; boundary="----=_Part_abc123"'; + expect(extractBoundary(ct)).toBe("----=_Part_abc123"); + }); + + test("extracts unquoted boundary", () => { + const ct = "multipart/mixed; boundary=simple_boundary"; + expect(extractBoundary(ct)).toBe("simple_boundary"); + }); + + test("returns undefined when no boundary", () => { + expect(extractBoundary("text/plain")).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// parseMultipart +// --------------------------------------------------------------------------- + +describe("parseMultipart", () => { + test("splits two parts correctly", () => { + const body = enc.encode( + [ + "--boundary", + "Content-Type: text/plain", + "", + "Part one", + "--boundary", + "Content-Type: text/html", + "", + "

Part two

", + "--boundary--", + ].join("\r\n"), + ); + const parts = parseMultipart(body, "boundary"); + expect(parts).toHaveLength(2); + expect(dec.decode(defined(parts[0]))).toContain("Part one"); + expect(dec.decode(defined(parts[1]))).toContain("

Part two

"); + }); + + test("handles LF-only line endings", () => { + const body = enc.encode( + "--boundary\nContent-Type: text/plain\n\nPart one\n--boundary\nContent-Type: text/html\n\n

Part two

\n--boundary--\n", + ); + const parts = parseMultipart(body, "boundary"); + expect(parts).toHaveLength(2); + const p1 = parseMimePart(defined(parts[0])); + const p2 = parseMimePart(defined(parts[1])); + expect(dec.decode(p1.body)).toContain("Part one"); + expect(dec.decode(p2.body)).toContain("

Part two

"); + }); +}); + +// --------------------------------------------------------------------------- +// parseMimePart +// --------------------------------------------------------------------------- + +describe("parseMimePart", () => { + test("separates headers from body", () => { + const raw = enc.encode("Content-Type: text/plain\r\n\r\nThe body text"); + const part = parseMimePart(raw); + expect(part.contentType).toBe("text/plain"); + expect(dec.decode(part.body)).toBe("The body text"); + }); + + test("defaults to application/octet-stream", () => { + const raw = enc.encode("X-Custom: value\r\n\r\ndata"); + const part = parseMimePart(raw); + expect(part.contentType).toBe("application/octet-stream"); + }); +}); + +// --------------------------------------------------------------------------- +// extractPartByPath +// --------------------------------------------------------------------------- + +describe("extractPartByPath", () => { + test("extracts parts from an assembled message", () => { + const content = assembleSignedContent({ + kind: "conversation", + text: "Hello world", + }); + const msg = assembleMessage(makeHeaders(), content, enc.encode("SIG")); + + const part1 = extractPartByPath(msg, "1"); + expect(dec.decode(part1)).toContain("Hello world"); + + const part2 = extractPartByPath(msg, "2"); + const sigPart = parseMimePart(part2); + expect(sigPart.contentType).toBe("application/pgp-signature"); + }); + + test("throws on invalid path segment", () => { + const msg = assembleMessage( + makeHeaders(), + assembleSignedContent({ kind: "conversation", text: "x" }), + enc.encode("SIG"), + ); + expect(() => extractPartByPath(msg, "0")).toThrow(/Invalid part path/); + expect(() => extractPartByPath(msg, "abc")).toThrow(/Invalid part path/); + }); + + test("throws when part index exceeds part count", () => { + const msg = assembleMessage( + makeHeaders(), + assembleSignedContent({ kind: "conversation", text: "x" }), + enc.encode("SIG"), + ); + expect(() => extractPartByPath(msg, "5")).toThrow(/does not exist/); + }); + + test("throws when indexing into a non-multipart message", () => { + const raw = enc.encode("Content-Type: text/plain\r\n\r\nJust a body"); + expect(() => extractPartByPath(raw, "1")).toThrow(/non-multipart/); + }); +}); + +// --------------------------------------------------------------------------- +// createDetachedSignatureFromProvider — round-trip with verify +// --------------------------------------------------------------------------- + +describe("createDetachedSignatureFromProvider", () => { + test("signature verifies against the signed content", async () => { + const kp = await generateKeyPair(); + const provider = createEd25519Crypto(kp); + const content = assembleSignedContent({ + kind: "conversation", + text: "Round-trip test", + }); + + const sig = await createDetachedSignatureFromProvider(content, provider); + const valid = await verifyDetachedSignature( + content, + sig, + provider.getPublicKey(), + ); + expect(valid).toBe(true); + }); + + test("signature is ASCII-armored", async () => { + const kp = await generateKeyPair(); + const provider = createEd25519Crypto(kp); + const content = assembleSignedContent({ + kind: "conversation", + text: "test", + }); + + const sig = await createDetachedSignatureFromProvider(content, provider); + const text = dec.decode(sig); + expect(text).toContain("-----BEGIN PGP SIGNATURE-----"); + expect(text).toContain("-----END PGP SIGNATURE-----"); + }); + + test("verification fails with wrong public key", async () => { + const kp1 = await generateKeyPair(); + const kp2 = await generateKeyPair(); + const provider = createEd25519Crypto(kp1); + const wrongKey = createEd25519Crypto(kp2); + const content = assembleSignedContent({ + kind: "conversation", + text: "test", + }); + + const sig = await createDetachedSignatureFromProvider(content, provider); + const valid = await verifyDetachedSignature( + content, + sig, + wrongKey.getPublicKey(), + ); + expect(valid).toBe(false); + }); + + test("verification fails with tampered content", async () => { + const kp = await generateKeyPair(); + const provider = createEd25519Crypto(kp); + const content = assembleSignedContent({ + kind: "conversation", + text: "original", + }); + + const sig = await createDetachedSignatureFromProvider(content, provider); + const tampered = assembleSignedContent({ + kind: "conversation", + text: "modified", + }); + const valid = await verifyDetachedSignature( + tampered, + sig, + provider.getPublicKey(), + ); + expect(valid).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// parseMailToEmail +// --------------------------------------------------------------------------- + +describe("parseMailToEmail", () => { + test("parses a simple text/plain message", () => { + const raw = enc.encode( + [ + "From: Alice ", + "To: Bob ", + "Subject: Hello", + "Date: Tue, 21 Apr 2026 12:00:00 +0000", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "", + "Hello from Alice", + ].join("\r\n"), + ); + + const email = parseMailToEmail(raw, "sml_abc123"); + + expect(email.from).toEqual([{ name: "Alice", email: "alice@example.com" }]); + expect(email.to).toEqual([{ name: "Bob", email: "bob@example.com" }]); + expect(email.subject).toBe("Hello"); + expect(email.sentAt).toBe("2026-04-21T12:00:00.000Z"); + expect(Object.keys(email.bodyValues)).toHaveLength(1); + expect(email.bodyValues["1"]?.value).toContain("Hello from Alice"); + expect(email.textBody).toEqual([{ partId: "1", type: "text/plain" }]); + expect(email.htmlBody).toHaveLength(0); + expect(email.attachments).toHaveLength(0); + }); + + test("parses from/to with bare email addresses", () => { + const raw = enc.encode( + [ + "From: alice@example.com", + "To: bob@example.com, charlie@example.com", + "Date: Tue, 21 Apr 2026 12:00:00 +0000", + "Content-Type: text/plain", + "", + "body", + ].join("\r\n"), + ); + + const email = parseMailToEmail(raw, "sml_1"); + + expect(email.from).toEqual([{ name: null, email: "alice@example.com" }]); + expect(email.to).toEqual([ + { name: null, email: "bob@example.com" }, + { name: null, email: "charlie@example.com" }, + ]); + }); + + test("returns null subject when header is absent", () => { + const raw = enc.encode( + [ + "From: alice@example.com", + "To: bob@example.com", + "Date: Tue, 21 Apr 2026 12:00:00 +0000", + "Content-Type: text/plain", + "", + "body", + ].join("\r\n"), + ); + + const email = parseMailToEmail(raw, "sml_1"); + expect(email.subject).toBeNull(); + }); + + test("returns null sentAt when Date header is absent", () => { + const raw = enc.encode( + [ + "From: alice@example.com", + "To: bob@example.com", + "Content-Type: text/plain", + "", + "body", + ].join("\r\n"), + ); + + const email = parseMailToEmail(raw, "sml_1"); + expect(email.sentAt).toBeNull(); + }); + + test("returns null sentAt when Date header is unparseable", () => { + const raw = enc.encode( + [ + "From: alice@example.com", + "To: bob@example.com", + "Date: not-a-date", + "Content-Type: text/plain", + "", + "body", + ].join("\r\n"), + ); + + const email = parseMailToEmail(raw, "sml_1"); + expect(email.sentAt).toBeNull(); + }); + + test("parses multipart/mixed with text and attachment", () => { + const boundary = "test_boundary_xyz"; + const raw = enc.encode( + [ + "From: alice@example.com", + "To: bob@example.com", + "Date: Tue, 21 Apr 2026 12:00:00 +0000", + `Content-Type: multipart/mixed; boundary="${boundary}"`, + "", + `--${boundary}`, + "Content-Type: text/plain; charset=utf-8", + "", + "The message body", + `--${boundary}`, + "Content-Type: application/pdf", + 'Content-Disposition: attachment; filename="report.pdf"', + "", + "PDF-BYTES-HERE", + `--${boundary}--`, + ].join("\r\n"), + ); + + const email = parseMailToEmail(raw, "sml_multi"); + + expect(email.textBody).toEqual([{ partId: "1", type: "text/plain" }]); + expect(email.bodyValues["1"]?.value).toContain("The message body"); + expect(email.attachments).toHaveLength(1); + expect(email.attachments[0]).toEqual({ + blobId: "blob_sml_multi_2", + name: "report.pdf", + type: "application/pdf", + size: "PDF-BYTES-HERE".length, + }); + }); + + test("parses multipart/mixed with html part", () => { + const boundary = "mixed_html_boundary"; + const raw = enc.encode( + [ + "From: alice@example.com", + "To: bob@example.com", + "Date: Tue, 21 Apr 2026 12:00:00 +0000", + `Content-Type: multipart/mixed; boundary="${boundary}"`, + "", + `--${boundary}`, + "Content-Type: text/html; charset=utf-8", + "", + "

Hello

", + `--${boundary}--`, + ].join("\r\n"), + ); + + const email = parseMailToEmail(raw, "sml_html"); + + expect(email.htmlBody).toEqual([{ partId: "1", type: "text/html" }]); + expect(email.bodyValues["1"]?.value).toContain("

Hello

"); + expect(email.textBody).toHaveLength(0); + expect(email.attachments).toHaveLength(0); + }); + + test("parses a multipart/signed conversation message assembled by this library", () => { + const content = assembleSignedContent({ + kind: "conversation", + text: "Hello from a signed message", + }); + const fakeSig = enc.encode("FAKE-SIGNATURE"); + const msg = assembleMessage( + makeHeaders({ + subject: "Signed Convo", + interchangeType: "conversation.message", + interchangeSessionId: "sess-42", + }), + content, + fakeSig, + ); + + const email = parseMailToEmail(msg, "sml_signed_plain"); + + expect(email.from).toEqual([ + { name: null, email: "alice@test.interchange" }, + ]); + expect(email.to).toEqual([{ name: null, email: "bob@test.interchange" }]); + expect(email.subject).toBe("Signed Convo"); + expect(email.sentAt).toBe("2026-04-21T12:00:00.000Z"); + expect(email.textBody).toHaveLength(1); + expect( + email.bodyValues[defined(email.textBody[0]).partId]?.value, + ).toContain("Hello from a signed message"); + expect(email.attachments).toHaveLength(0); + expect(email.headers["interchange-type"]).toBe("conversation.message"); + expect(email.headers["interchange-session-id"]).toBe("sess-42"); + }); + + test("parses a multipart/signed structured message assembled by this library", () => { + const payload = { action: "deploy", env: "staging" }; + const content = assembleSignedContent({ + kind: "structured", + json: payload, + summary: "Deploying to staging", + }); + const fakeSig = enc.encode("FAKE-SIG"); + const msg = assembleMessage( + makeHeaders({ interchangeType: "structured.message" }), + content, + fakeSig, + ); + + const email = parseMailToEmail(msg, "sml_signed_structured"); + + // The structured message is multipart/mixed inside multipart/signed. + // textBody should include the summary text/plain part. + expect(email.textBody).toHaveLength(1); + const textPartId = defined(email.textBody[0]).partId; + expect(email.bodyValues[textPartId]?.value).toContain( + "Deploying to staging", + ); + // The application/vnd.interchange+json part is a non-text blob attachment. + expect(email.attachments).toHaveLength(1); + expect(email.attachments[0]?.type).toBe("application/vnd.interchange+json"); + expect(email.headers["interchange-type"]).toBe("structured.message"); + }); + + test("blob IDs use the correct scheme", () => { + const boundary = "blob_id_boundary"; + const raw = enc.encode( + [ + "From: alice@example.com", + "To: bob@example.com", + "Date: Tue, 21 Apr 2026 12:00:00 +0000", + `Content-Type: multipart/mixed; boundary="${boundary}"`, + "", + `--${boundary}`, + "Content-Type: text/plain", + "", + "Text body", + `--${boundary}`, + "Content-Type: image/png", + 'Content-Disposition: attachment; filename="photo.png"', + "", + "PNG-DATA", + `--${boundary}`, + "Content-Type: application/zip", + 'Content-Disposition: attachment; filename="archive.zip"', + "", + "ZIP-DATA", + `--${boundary}--`, + ].join("\r\n"), + ); + + const email = parseMailToEmail(raw, "sml_xyz"); + + expect(email.attachments[0]?.blobId).toBe("blob_sml_xyz_2"); + expect(email.attachments[1]?.blobId).toBe("blob_sml_xyz_3"); + }); + + test("extracts Interchange-specific headers into headers field", () => { + const raw = enc.encode( + [ + "From: alice@example.com", + "To: bob@example.com", + "Date: Tue, 21 Apr 2026 12:00:00 +0000", + "Content-Type: text/plain", + "Interchange-Type: conversation.message", + "Interchange-Tenant-ID: tenant-99", + "Interchange-Agent-ID: agent-42", + "X-Custom: should-not-appear", + "", + "body", + ].join("\r\n"), + ); + + const email = parseMailToEmail(raw, "sml_hdrs"); + + expect(email.headers["interchange-type"]).toBe("conversation.message"); + expect(email.headers["interchange-tenant-id"]).toBe("tenant-99"); + expect(email.headers["interchange-agent-id"]).toBe("agent-42"); + expect(email.headers["x-custom"]).toBeUndefined(); + }); + + test("non-text non-attachment parts are treated as attachments", () => { + const boundary = "mixed_types"; + const raw = enc.encode( + [ + "From: alice@example.com", + "To: bob@example.com", + "Date: Tue, 21 Apr 2026 12:00:00 +0000", + `Content-Type: multipart/mixed; boundary="${boundary}"`, + "", + `--${boundary}`, + "Content-Type: text/plain", + "", + "Text here", + `--${boundary}`, + "Content-Type: application/octet-stream", + 'Content-Disposition: attachment; filename="data.bin"', + "", + "BINARY", + `--${boundary}--`, + ].join("\r\n"), + ); + + const email = parseMailToEmail(raw, "sml_bin"); + + expect(email.textBody).toHaveLength(1); + expect(email.attachments).toHaveLength(1); + expect(email.attachments[0]?.type).toBe("application/octet-stream"); + expect(email.attachments[0]?.name).toBe("data.bin"); + }); +}); + +// --------------------------------------------------------------------------- +// Full round-trip: assemble → parse → verify +// --------------------------------------------------------------------------- + +describe("assemble then parse round-trip", () => { + test("conversation message survives assemble/parse cycle", async () => { + const kp = await generateKeyPair(); + const provider = createEd25519Crypto(kp); + const content = assembleSignedContent({ + kind: "conversation", + text: "Hello from the round-trip test", + }); + const sig = await createDetachedSignatureFromProvider(content, provider); + const msg = assembleMessage( + makeHeaders({ interchangeType: "conversation.message" }), + content, + sig, + ); + + const { headers, bodyOffset } = parseHeaderSection(msg); + expect(headers.get("from")).toBe("alice@test.interchange"); + expect(headers.get("interchange-type")).toBe("conversation.message"); + + const ct = defined(headers.get("content-type")); + expect(ct).toContain("multipart/signed"); + const boundary = defined(extractBoundary(ct)); + + const body = msg.slice(bodyOffset); + const parts = parseMultipart(body, boundary); + expect(parts).toHaveLength(2); + + const signedPart = defined(parts[0]); + const sigPart = parseMimePart(defined(parts[1])); + expect(sigPart.contentType).toBe("application/pgp-signature"); + + const valid = await verifyDetachedSignature( + signedPart, + sigPart.body, + provider.getPublicKey(), + ); + expect(valid).toBe(true); + + // The signed content is multipart/mixed; the text lives at part 1.1. + const parsed = parseMimePart(signedPart); + expect(parsed.contentType).toContain("multipart/mixed"); + const innerBoundary = defined(extractBoundary(parsed.contentType)); + const innerParts = parseMultipart(parsed.body, innerBoundary); + const textPart = parseMimePart(defined(innerParts[0])); + expect(textPart.contentType).toContain("text/plain"); + expect(dec.decode(textPart.body)).toContain( + "Hello from the round-trip test", + ); + }); + + test("structured message survives assemble/parse cycle", async () => { + const kp = await generateKeyPair(); + const provider = createEd25519Crypto(kp); + const payload = { action: "deploy", target: "prod" }; + const content = assembleSignedContent({ + kind: "structured", + json: payload, + summary: "Deploying to prod", + }); + const sig = await createDetachedSignatureFromProvider(content, provider); + const msg = assembleMessage(makeHeaders(), content, sig); + + const { headers, bodyOffset } = parseHeaderSection(msg); + const outerBoundary = defined( + extractBoundary(defined(headers.get("content-type"))), + ); + const outerParts = parseMultipart(msg.slice(bodyOffset), outerBoundary); + expect(outerParts).toHaveLength(2); + + const signedPart = defined(outerParts[0]); + const innerParsed = parseMimePart(signedPart); + expect(innerParsed.contentType).toContain("multipart/mixed"); + + const innerBoundary = defined(extractBoundary(innerParsed.contentType)); + const innerParts = parseMultipart(innerParsed.body, innerBoundary); + expect(innerParts).toHaveLength(2); + + const jsonPart = parseMimePart(defined(innerParts[0])); + expect(jsonPart.contentType).toContain("application/vnd.interchange+json"); + const parsed = JSON.parse(dec.decode(jsonPart.body)); + expect(parsed).toEqual(payload); + + const summaryPart = parseMimePart(defined(innerParts[1])); + expect(dec.decode(summaryPart.body)).toContain("Deploying to prod"); + }); +}); + +// --------------------------------------------------------------------------- +// Conversation attachments: assemble → extract round-trip +// --------------------------------------------------------------------------- + +describe("conversation attachments round-trip", () => { + const attachments: MessageAttachment[] = [ + { + name: "shot.png", + contentType: "image/png", + data: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + }, + { + name: "clip.mp4", + contentType: "video/mp4", + data: new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), + }, + { + name: "voice.mp3", + contentType: "audio/mpeg", + data: new Uint8Array([0xff, 0xfb, 0x90, 0x00, 0x11]), + }, + { + name: "report.pdf", + contentType: "application/pdf", + data: new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]), + }, + ]; + + function buildConversation( + text: string, + atts: MessageAttachment[] | undefined, + ): Uint8Array { + const content = assembleSignedContent({ + kind: "conversation", + text, + ...(atts !== undefined ? { attachments: atts } : {}), + }); + return assembleMessage( + makeHeaders({ interchangeType: "conversation.message" }), + content, + enc.encode("FAKE-SIGNATURE"), + ); + } + + test("assemble then extract reconstructs one attachment of each block variant", () => { + const msg = buildConversation("see attached", attachments); + const extracted = extractAttachments(msg); + expect(extracted).toHaveLength(attachments.length); + for (let i = 0; i < attachments.length; i++) { + const orig = defined(attachments[i]); + const got = defined(extracted[i]); + expect(got.name).toBe(orig.name); + expect(got.contentType).toBe(orig.contentType); + expect(Array.from(got.data)).toEqual(Array.from(orig.data)); + } + }); + + test("no-attachments conversation round-trips with zero attachment parts", () => { + const msg = buildConversation("just text", undefined); + expect(extractAttachments(msg)).toHaveLength(0); + }); + + test("image-only conversation (empty text) round-trips the attachment", () => { + const onlyImage = defined(attachments[0]); + const msg = buildConversation("", [onlyImage]); + const extracted = extractAttachments(msg); + expect(extracted).toHaveLength(1); + expect(defined(extracted[0]).contentType).toBe("image/png"); + expect(Array.from(defined(extracted[0]).data)).toEqual( + Array.from(onlyImage.data), + ); + }); + + test("parseMailToEmail surfaces conversation attachment metadata", () => { + const pdf = defined(attachments[3]); + const msg = buildConversation("hi", [pdf]); + const email = parseMailToEmail(msg, "sml_conv_att"); + expect(email.attachments).toHaveLength(1); + expect(defined(email.attachments[0]).name).toBe("report.pdf"); + expect(defined(email.attachments[0]).type).toBe("application/pdf"); + }); + + test("a text/plain document attachment is distinguished from the body text", () => { + // The trickiest case: a text/plain attachment shares its content type + // with the conversation body part, so the two are told apart only by + // Content-Disposition. The body must not be read as an attachment, and + // the attachment must not be lost. + const doc: MessageAttachment = { + name: "notes.txt", + contentType: "text/plain", + data: enc.encode("attached document contents"), + }; + const msg = buildConversation("the conversation body", [doc]); + + const extracted = extractAttachments(msg); + expect(extracted).toHaveLength(1); + expect(defined(extracted[0]).name).toBe("notes.txt"); + expect(defined(extracted[0]).contentType).toBe("text/plain"); + expect(dec.decode(defined(extracted[0]).data)).toBe( + "attached document contents", + ); + + const email = parseMailToEmail(msg, "sml_txt_doc"); + expect(email.attachments).toHaveLength(1); + expect(defined(email.attachments[0]).name).toBe("notes.txt"); + expect(defined(email.attachments[0]).type).toBe("text/plain"); + }); + + test("conversation message with attachments produces a verifiable signature", async () => { + const kp = await generateKeyPair(); + const provider = createEd25519Crypto(kp); + const content = assembleSignedContent({ + kind: "conversation", + text: "signed with an image", + attachments: [defined(attachments[0])], + }); + const sig = await createDetachedSignatureFromProvider(content, provider); + const msg = assembleMessage( + makeHeaders({ interchangeType: "conversation.message" }), + content, + sig, + ); + + const { headers, bodyOffset } = parseHeaderSection(msg); + const boundary = defined( + extractBoundary(defined(headers.get("content-type"))), + ); + const parts = parseMultipart(msg.slice(bodyOffset), boundary); + const signedPart = defined(parts[0]); + const sigPart = parseMimePart(defined(parts[1])); + const valid = await verifyDetachedSignature( + signedPart, + sigPart.body, + provider.getPublicKey(), + ); + expect(valid).toBe(true); + }); + + test("rejects an attachment name containing CRLF (header injection)", () => { + expect(() => + assembleSignedContent({ + kind: "conversation", + text: "x", + attachments: [ + { + name: "evil\r\nContent-Type: text/html", + contentType: "image/png", + data: new Uint8Array([1, 2, 3]), + }, + ], + }), + ).toThrow(); + }); +}); diff --git a/vendor/intx-mime/src/index.ts b/vendor/intx-mime/src/index.ts new file mode 100644 index 000000000..a2029561d --- /dev/null +++ b/vendor/intx-mime/src/index.ts @@ -0,0 +1,44 @@ +export { + assembleSignedContent, + assembleMessage, + extractAddrSpec, + formatRFC2822Date, + generateMessageId, + parseHeaderSection, + parseMimePart, + parseMultipart, + extractBoundary, + extractPartByPath, + parseMailToEmail, + extractAttachments, + buildMessageHeaders, + decodeMail, +} from "./mime"; + +export type { + MessageHeaders, + ConversationContent, + MimeAssemblyInput, + StructuredContent, + ParsedMimePart, + ParsedMimeMessage, + JMAPEmail, + JMAPAddress, + JMAPBodyValue, + JMAPBodyPart, + JMAPAttachment, +} from "./mime"; + +export { createDetachedSignatureFromProvider } from "./pgp-sign"; + +export { + createInboundMessage, + createOutboundMessage, + isMessageId, +} from "./mail-builder"; + +export type { + CreateInboundMessageOpts, + CreateOutboundMessageOpts, + InboundPayloadInput, +} from "./mail-builder"; diff --git a/vendor/intx-mime/src/mail-builder.test.ts b/vendor/intx-mime/src/mail-builder.test.ts new file mode 100644 index 000000000..95ca4e37d --- /dev/null +++ b/vendor/intx-mime/src/mail-builder.test.ts @@ -0,0 +1,740 @@ +import { describe, test, expect } from "bun:test"; +import type { + InboundMessage, + MessageAttachment, + OutboundMessage, +} from "@intx/types/runtime"; + +import { createInboundMessage, createOutboundMessage } from "./mail-builder"; +import type { + CreateInboundMessageOpts, + CreateOutboundMessageOpts, +} from "./mail-builder"; + +const FROM = "alice@example.com"; +const TO = "agent@example.com"; + +// Test-only escape hatches. The builders' types reject obviously invalid +// inputs at compile time, but the defensive validation also has to surface +// errors when callers bypass the type system (e.g. data deserialised from +// unknown JSON). These helpers route around the type checker so the +// runtime guards can be exercised directly. +function callInboundUnsafe(opts: unknown): unknown { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- exercise runtime validation against type-violating input + return createInboundMessage(opts as CreateInboundMessageOpts); +} +function callOutboundUnsafe(opts: unknown): unknown { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- exercise runtime validation against type-violating input + return createOutboundMessage(opts as CreateOutboundMessageOpts); +} + +describe("createInboundMessage", () => { + test("conversation content with default ref/flags/signatureStatus", () => { + const msg = createInboundMessage({ from: FROM, to: TO, content: "hi" }); + + expect(msg.ref).toEqual({ uid: 1, mailbox: "INBOX" }); + expect(msg.flags).toEqual([]); + expect(msg.signatureStatus).toBe("missing"); + expect(msg.content).toBe("hi"); + expect(msg.payload).toBeUndefined(); + expect(msg.attachments).toBeUndefined(); + expect(msg.headers.from).toBe(FROM); + expect(msg.headers.to).toEqual([TO]); + expect(msg.headers.interchangeType).toBeUndefined(); + }); + + test("auto-generated messageId derives domain from `from`", () => { + const msg = createInboundMessage({ from: FROM, to: TO, content: "hi" }); + expect(msg.headers.messageId).toMatch(/^<[^<>\s]+@example\.com>$/); + }); + + 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); + expect(Number.isNaN(parsed.getTime())).toBe(false); + expect(msg.headers.date).toBe(parsed.toISOString()); + }); + + test("structured payload sets interchangeType header automatically", () => { + const msg = createInboundMessage({ + from: FROM, + to: TO, + payload: { + type: "offering.request", + body: { offeringId: "code-review", parameters: {} }, + }, + }); + + expect(msg.payload).toEqual({ + type: "offering.request", + version: "1", + body: { offeringId: "code-review", parameters: {} }, + }); + expect(msg.headers.interchangeType).toBe("offering.request"); + expect(msg.content).toBeUndefined(); + }); + + test("payload.version override is preserved", () => { + const msg = createInboundMessage({ + from: FROM, + to: TO, + payload: { + type: "payment.required", + version: "2", + body: { amount: "0.50" }, + }, + }); + expect(msg.payload?.version).toBe("2"); + }); + + test("attachments are passed through when non-empty", () => { + const attachments: MessageAttachment[] = [ + { + name: "report.pdf", + contentType: "application/pdf", + data: new Uint8Array([1, 2, 3]), + }, + ]; + const msg = createInboundMessage({ + from: FROM, + to: TO, + content: "see attached", + attachments, + }); + expect(msg.attachments).toEqual(attachments); + }); + + test("threading headers are wired through", () => { + const msg = createInboundMessage({ + from: FROM, + to: TO, + content: "reply", + inReplyTo: "", + references: ["", ""], + correlationId: "corr-123", + }); + + expect(msg.headers.inReplyTo).toBe(""); + expect(msg.headers.references).toEqual([ + "", + "", + ]); + expect(msg.headers.interchangeCorrelationId).toBe("corr-123"); + }); + + test("all interchange identity headers map through", () => { + const msg = createInboundMessage({ + from: FROM, + to: TO, + content: "hi", + tenantId: "tenant-1", + agentId: "agent-1", + sessionId: "session-1", + offeringId: "offering-1", + schemaVersion: "1", + traceparent: "00-trace-span-01", + tracestate: "vendor=foo", + listId: "list-1", + }); + + expect(msg.headers.interchangeTenantId).toBe("tenant-1"); + expect(msg.headers.interchangeAgentId).toBe("agent-1"); + expect(msg.headers.interchangeSessionId).toBe("session-1"); + expect(msg.headers.interchangeOfferingId).toBe("offering-1"); + expect(msg.headers.interchangeSchemaVersion).toBe("1"); + expect(msg.headers.traceparent).toBe("00-trace-span-01"); + expect(msg.headers.tracestate).toBe("vendor=foo"); + expect(msg.headers.listId).toBe("list-1"); + }); + + test("ref override merges with synthetic defaults", () => { + const a = createInboundMessage({ + from: FROM, + to: TO, + content: "x", + ref: { uid: 42 }, + }); + expect(a.ref).toEqual({ uid: 42, mailbox: "INBOX" }); + + const b = createInboundMessage({ + from: FROM, + to: TO, + content: "x", + ref: { mailbox: "Trash" }, + }); + expect(b.ref).toEqual({ uid: 1, mailbox: "Trash" }); + }); + + test("Date input is normalised to an ISO string", () => { + const fixed = new Date("2026-01-02T03:04:05.000Z"); + const msg = createInboundMessage({ + from: FROM, + to: TO, + content: "x", + date: fixed, + }); + expect(msg.headers.date).toBe("2026-01-02T03:04:05.000Z"); + }); + + test("flags and signatureStatus overrides are respected", () => { + const msg = createInboundMessage({ + from: FROM, + to: TO, + content: "x", + flags: ["\\Seen"], + signatureStatus: "valid", + }); + expect(msg.flags).toEqual(["\\Seen"]); + expect(msg.signatureStatus).toBe("valid"); + }); + + test("cc accepts a string and normalises to an array", () => { + const msg = createInboundMessage({ + from: FROM, + to: TO, + content: "x", + cc: "watcher@example.com", + }); + expect(msg.headers.cc).toEqual(["watcher@example.com"]); + }); + + test("control-frame (no content, no payload) is allowed", () => { + const msg = createInboundMessage({ from: FROM, to: TO }); + expect(msg.content).toBeUndefined(); + expect(msg.payload).toBeUndefined(); + expect(msg.flags).toEqual([]); + }); + + test("explicit interchangeType is allowed for content-only messages", () => { + const msg = createInboundMessage({ + from: FROM, + to: TO, + content: "join", + interchangeType: "conversation.join", + }); + expect(msg.headers.interchangeType).toBe("conversation.join"); + }); + + describe("validation", () => { + test("throws when both content and payload are set", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + payload: { type: "offering.request", body: {} }, + }), + ).toThrow(/`content` and `payload` are mutually exclusive/); + }); + + test("throws when from is empty", () => { + expect(() => + createInboundMessage({ from: "", to: TO, content: "x" }), + ).toThrow(/`from` must be a non-empty string/); + }); + + test("throws when to is an empty array", () => { + expect(() => + createInboundMessage({ from: FROM, to: [], content: "x" }), + ).toThrow(/`to` must contain at least one recipient address/); + }); + + test("throws when to contains an empty string", () => { + expect(() => + createInboundMessage({ from: FROM, to: [""], content: "x" }), + ).toThrow(/`to\[0\]` must be a non-empty string/); + }); + + test("throws when payload.type is not a known InterchangeType", () => { + expect(() => + callInboundUnsafe({ + from: FROM, + to: TO, + payload: { + type: "not.a.real.type", + body: {}, + }, + }), + ).toThrow(/`payload.type` is not a valid InterchangeType/); + }); + + test("throws when interchangeType conflicts with payload.type", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + payload: { type: "offering.request", body: {} }, + interchangeType: "payment.required", + }), + ).toThrow( + /`interchangeType` \(payment\.required\) conflicts with `payload\.type` \(offering\.request\)/, + ); + }); + + test("throws when messageId lacks angle brackets", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + messageId: "missing-brackets@example.com", + }), + ).toThrow(/`messageId` must be an RFC 2822 message identifier/); + }); + + test("throws when inReplyTo lacks angle brackets", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + inReplyTo: "bare", + }), + ).toThrow(/`inReplyTo` must be an RFC 2822 message identifier/); + }); + + test("throws when references contains a malformed entry", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + references: ["", "bad"], + }), + ).toThrow(/`references\[1\]` must be an RFC 2822 message identifier/); + }); + + test("throws when references is an empty array", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + references: [], + }), + ).toThrow(/`references`, when provided, must contain at least one entry/); + }); + + test("throws when date string is not parseable", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + date: "not-a-date", + }), + ).toThrow(/`date` is not a parseable date string/); + }); + + test("throws when date is an Invalid Date instance", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + date: new Date("nope"), + }), + ).toThrow(/`date` is an Invalid Date/); + }); + + test("throws when optional string fields are empty", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + correlationId: "", + }), + ).toThrow(/`correlationId`, when provided, must be a non-empty string/); + }); + + test("throws when flags contains an empty string", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + flags: ["\\Seen", ""], + }), + ).toThrow(/`flags\[1\]` must be a non-empty string/); + }); + + test("throws when ref.mailbox is empty", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + ref: { mailbox: "" }, + }), + ).toThrow(/`ref\.mailbox`.*must be a non-empty string/); + }); + + test("throws when from lacks an @ domain", () => { + expect(() => + createInboundMessage({ + from: "alice", + to: TO, + content: "x", + }), + ).toThrow(/`from` must be an RFC 5322 address/); + }); + + test("throws when a `to` entry lacks an @ domain", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: ["agent@example.com", "loose-string"], + content: "x", + }), + ).toThrow(/`to\[1\]` must be an RFC 5322 address/); + }); + + test("throws when messageId has angle brackets but no @ host", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + messageId: "", + }), + ).toThrow(/`messageId` must be an RFC 2822 message identifier/); + }); + + test("throws when payload.type is a conversation type", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + payload: { type: "conversation.message", body: {} }, + }), + ).toThrow(/conversation types must use `content` instead of `payload`/); + }); + + test("throws when payload.body is null", () => { + expect(() => + callInboundUnsafe({ + from: FROM, + to: TO, + payload: { type: "offering.request", body: null }, + }), + ).toThrow(/`payload\.body` must be a plain object/); + }); + + test("throws when payload.body is an array", () => { + expect(() => + callInboundUnsafe({ + from: FROM, + to: TO, + payload: { type: "offering.request", body: [] }, + }), + ).toThrow(/`payload\.body` must be a plain object/); + }); + + test("throws when payload.version is not a string", () => { + expect(() => + callInboundUnsafe({ + from: FROM, + to: TO, + payload: { type: "offering.request", body: {}, version: 7 }, + }), + ).toThrow(/`payload\.version`.*must be a non-empty string/); + }); + + test("throws when ref.uid is not an integer", () => { + expect(() => + callInboundUnsafe({ + from: FROM, + to: TO, + content: "x", + ref: { uid: "not-a-number" }, + }), + ).toThrow(/`ref\.uid`.*must be a positive integer/); + }); + + test("throws when content is the empty string", () => { + expect(() => + createInboundMessage({ from: FROM, to: TO, content: "" }), + ).toThrow(/`content`, when provided, must be a non-empty string/); + }); + + test("throws when signatureStatus is not a recognised value", () => { + expect(() => + callInboundUnsafe({ + from: FROM, + to: TO, + content: "x", + signatureStatus: "bogus", + }), + ).toThrow(/`signatureStatus` is not a recognised SignatureStatus/); + }); + + test("throws when ref.uid is zero", () => { + expect(() => + createInboundMessage({ + from: FROM, + to: TO, + content: "x", + ref: { uid: 0 }, + }), + ).toThrow(/`ref\.uid`.*must be a positive integer/); + }); + }); +}); + +describe("createOutboundMessage", () => { + test("conversation content with required fields", () => { + const msg = createOutboundMessage({ + to: TO, + type: "conversation.message", + content: "hi", + }); + expect(msg).toEqual({ + to: TO, + type: "conversation.message", + content: "hi", + }); + }); + + test("structured payload with summary and attachments", () => { + const attachments: MessageAttachment[] = [ + { + name: "x.bin", + contentType: "application/octet-stream", + data: new Uint8Array([0]), + }, + ]; + const msg = createOutboundMessage({ + to: TO, + type: "offering.request", + payload: { offeringId: "code-review", parameters: {} }, + summary: "Code review request", + attachments, + }); + expect(msg.type).toBe("offering.request"); + expect(msg.payload).toEqual({ offeringId: "code-review", parameters: {} }); + expect(msg.summary).toBe("Code review request"); + expect(msg.attachments).toEqual(attachments); + expect(msg.content).toBeUndefined(); + }); + + test("to/cc are passed through without normalisation", () => { + const single = createOutboundMessage({ + to: "single@example.com", + type: "conversation.message", + content: "hi", + cc: "watcher@example.com", + }); + expect(single.to).toBe("single@example.com"); + expect(single.cc).toBe("watcher@example.com"); + + const many = createOutboundMessage({ + to: ["a@example.com", "b@example.com"], + type: "conversation.message", + content: "hi", + cc: ["c@example.com"], + }); + expect(many.to).toEqual(["a@example.com", "b@example.com"]); + expect(many.cc).toEqual(["c@example.com"]); + }); + + test("threading fields are wired through", () => { + const msg = createOutboundMessage({ + to: TO, + type: "approval.granted", + payload: { decision: "approved" }, + inReplyTo: "", + correlationId: "corr-abc", + sessionId: "session-1", + tenantId: "tenant-1", + }); + expect(msg.inReplyTo).toBe(""); + expect(msg.correlationId).toBe("corr-abc"); + expect(msg.sessionId).toBe("session-1"); + expect(msg.tenantId).toBe("tenant-1"); + }); + + describe("validation", () => { + test("throws when type is invalid", () => { + expect(() => + callOutboundUnsafe({ + to: TO, + type: "not.real", + content: "x", + }), + ).toThrow(/`type` is not a valid InterchangeType/); + }); + + test("throws when content and payload are both set", () => { + expect(() => + createOutboundMessage({ + to: TO, + type: "offering.request", + content: "x", + payload: { offeringId: "x" }, + }), + ).toThrow(/`content` and `payload` are mutually exclusive/); + }); + + test("throws when to is empty string", () => { + expect(() => + createOutboundMessage({ + to: "", + type: "conversation.message", + content: "x", + }), + ).toThrow(/`to` must be a non-empty string/); + }); + + test("throws when to is empty array", () => { + expect(() => + createOutboundMessage({ + to: [], + type: "conversation.message", + content: "x", + }), + ).toThrow(/`to` must contain at least one recipient address/); + }); + + test("throws when to array has empty entry", () => { + expect(() => + createOutboundMessage({ + to: ["valid@example.com", ""], + type: "conversation.message", + content: "x", + }), + ).toThrow(/`to\[1\]` must be a non-empty string/); + }); + + test("throws when cc array has empty entry", () => { + expect(() => + createOutboundMessage({ + to: TO, + type: "conversation.message", + content: "x", + cc: [""], + }), + ).toThrow(/`cc\[0\]` must be a non-empty string/); + }); + + test("throws when inReplyTo lacks angle brackets", () => { + expect(() => + createOutboundMessage({ + to: TO, + type: "conversation.message", + content: "x", + inReplyTo: "bare-id", + }), + ).toThrow(/`inReplyTo` must be an RFC 2822 message identifier/); + }); + + test("throws when summary is empty", () => { + expect(() => + createOutboundMessage({ + to: TO, + type: "offering.request", + payload: { x: 1 }, + summary: "", + }), + ).toThrow(/`summary`, when provided, must be a non-empty string/); + }); + + test("throws when type is conversation.* with payload", () => { + expect(() => + createOutboundMessage({ + to: TO, + type: "conversation.message", + payload: { x: 1 }, + }), + ).toThrow( + /conversation `type` conversation\.message must use `content` instead of `payload`/, + ); + }); + + test("throws when type is non-conversation with content", () => { + expect(() => + createOutboundMessage({ + to: TO, + type: "offering.request", + content: "hi", + }), + ).toThrow( + /non-conversation `type` offering\.request must use `payload` instead of `content`/, + ); + }); + + test("throws when payload is null", () => { + expect(() => + callOutboundUnsafe({ + to: TO, + type: "offering.request", + payload: null, + }), + ).toThrow(/`payload` must be a plain object/); + }); + + test("throws when payload is an array", () => { + expect(() => + callOutboundUnsafe({ + to: TO, + type: "offering.request", + payload: [1, 2, 3], + }), + ).toThrow(/`payload` must be a plain object/); + }); + + test("throws when content is the empty string", () => { + expect(() => + createOutboundMessage({ + to: TO, + type: "conversation.message", + content: "", + }), + ).toThrow(/`content`, when provided, must be a non-empty string/); + }); + + test("throws when conversation type is missing content", () => { + expect(() => + createOutboundMessage({ + to: TO, + type: "conversation.message", + }), + ).toThrow(/conversation `type` conversation\.message requires `content`/); + }); + + test("throws when non-conversation type is missing payload", () => { + expect(() => + createOutboundMessage({ + to: TO, + type: "offering.request", + }), + ).toThrow(/non-conversation `type` offering\.request requires `payload`/); + }); + + test("throws when a to entry lacks an @ domain", () => { + expect(() => + createOutboundMessage({ + to: ["agent@example.com", "loose-string"], + type: "conversation.message", + content: "x", + }), + ).toThrow(/`to\[1\]` must be an RFC 5322 address/); + }); + }); +}); + +// Compile-time guards: the public return types of the builders match +// InboundMessage / OutboundMessage exactly. Variables are unused at runtime; +// referenced via the `_` prefix to satisfy lint. +const _inbound: InboundMessage = createInboundMessage({ + from: FROM, + to: TO, + content: "x", +}); +const _outbound: OutboundMessage = createOutboundMessage({ + to: TO, + type: "conversation.message", + content: "x", +}); +void _inbound; +void _outbound; diff --git a/vendor/intx-mime/src/mail-builder.ts b/vendor/intx-mime/src/mail-builder.ts new file mode 100644 index 000000000..65c3ff388 --- /dev/null +++ b/vendor/intx-mime/src/mail-builder.ts @@ -0,0 +1,516 @@ +/** + * Builders for InboundMessage and OutboundMessage shapes. + * + * Constructing these by hand requires assembling MessageRef, MessageHeaders, + * payload envelopes, signature status, and other mail-shaped fields that the + * transport normally produces after parsing wire bytes. These builders + * collapse that boilerplate behind two factories with sensible defaults. + * + * The builders use the parsed-shape MessageHeaders from + * @intx/types/runtime (where date is an ISO string), NOT the + * wire-shape MessageHeaders local to this package (where date is a Date + * object and headers are serialised to RFC 2822 bytes via assembleMessage). + * + * Consumers import the message types from @intx/types directly; the + * @intx/mime barrel does not re-export them. + */ + +import { type } from "arktype"; +import type { + InboundMessage, + MessageAttachment, + MessageHeaders, + MessageRef, + OutboundMessage, +} from "@intx/types/runtime"; +import { InterchangeType, SignatureStatus } from "@intx/types/runtime"; +import { generateMessageId } from "./mime"; + +/** + * Default schema version for structured payloads. Matches + * docs/MESSAGE.md § Payload Structure, which specifies "version": "1" as + * the current schema version for every Interchange payload type. Audit + * this default whenever the documented schema version increments. + */ +const DEFAULT_PAYLOAD_VERSION = "1"; + +const MESSAGE_ID_RE = /^<[^<>\s@]+@[^<>\s@]+>$/; +const ADDRESS_RE = /^[^@\s]+@[^@\s]+$/; + +const CONVERSATION_TYPE_PREFIX = "conversation."; + +// --------------------------------------------------------------------------- +// InboundMessage builder +// --------------------------------------------------------------------------- + +/** + * Structured payload envelope for an inbound message. `version` defaults to + * the current schema version per docs/MESSAGE.md. + */ +export type InboundPayloadInput = { + type: InterchangeType; + body: Record; + version?: string; +}; + +export type CreateInboundMessageOpts = { + from: string; + to: string | string[]; + + /** Plain-text body. Mutually exclusive with `payload`. */ + content?: string; + + /** Structured JSON envelope. Mutually exclusive with `content`. */ + payload?: InboundPayloadInput; + + cc?: string | string[]; + subject?: string; + + /** + * Defaults to `new Date().toISOString()`. Accepts Date or any string + * parseable by `new Date(...)`; stored as an ISO 8601 string. + */ + date?: Date | string; + + /** Defaults to `generateMessageId(from)`. Must be of the form ``. */ + messageId?: string; + + inReplyTo?: string; + references?: string[]; + listId?: string; + + /** + * Interchange-Type header value. Auto-derived from `payload.type` when a + * payload is supplied; throws if explicitly set to a value that conflicts + * with `payload.type`. + */ + interchangeType?: InterchangeType; + + correlationId?: string; + tenantId?: string; + agentId?: string; + sessionId?: string; + offeringId?: string; + schemaVersion?: string; + traceparent?: string; + tracestate?: string; + + attachments?: MessageAttachment[]; + + /** Merged with `{ uid: 1, mailbox: "INBOX" }`. */ + ref?: Partial; + + flags?: string[]; + + /** Defaults to `"missing"`. */ + signatureStatus?: SignatureStatus; +}; + +export function createInboundMessage( + opts: CreateInboundMessageOpts, +): InboundMessage { + const fn = "createInboundMessage"; + + requireAddress(opts.from, "from", fn); + const to = normalizeAndValidateAddressArray(opts.to, "to", fn); + + validateBodyExclusivity(opts.content, opts.payload, fn); + + if (opts.payload !== undefined) { + validateInterchangeType(opts.payload.type, "payload.type", fn); + if (isConversationType(opts.payload.type)) { + throw new Error( + `${fn}: conversation types must use \`content\` instead of \`payload\`; got \`payload.type\`: ${opts.payload.type}`, + ); + } + validatePayloadBody(opts.payload.body, "payload.body", fn); + if (opts.payload.version !== undefined) { + if ( + typeof opts.payload.version !== "string" || + opts.payload.version.length === 0 + ) { + throw new Error( + `${fn}: \`payload.version\`, when provided, must be a non-empty string`, + ); + } + } + } + + if (opts.interchangeType !== undefined) { + validateInterchangeType(opts.interchangeType, "interchangeType", fn); + if ( + opts.payload !== undefined && + opts.interchangeType !== opts.payload.type + ) { + throw new Error( + `${fn}: \`interchangeType\` (${opts.interchangeType}) conflicts with \`payload.type\` (${opts.payload.type})`, + ); + } + } + + if (opts.messageId !== undefined) { + validateMessageId(opts.messageId, "messageId", fn); + } + if (opts.inReplyTo !== undefined) { + validateMessageId(opts.inReplyTo, "inReplyTo", fn); + } + if (opts.references !== undefined) { + if (opts.references.length === 0) { + throw new Error( + `${fn}: \`references\`, when provided, must contain at least one entry`, + ); + } + opts.references.forEach((ref, i) => { + validateMessageId(ref, `references[${i}]`, fn); + }); + } + + const cc = + opts.cc === undefined + ? undefined + : normalizeAndValidateAddressArray(opts.cc, "cc", fn); + + rejectEmptyStringIfPresent(opts.content, "content", fn); + rejectEmptyStringIfPresent(opts.subject, "subject", fn); + rejectEmptyStringIfPresent(opts.listId, "listId", fn); + rejectEmptyStringIfPresent(opts.correlationId, "correlationId", fn); + rejectEmptyStringIfPresent(opts.tenantId, "tenantId", fn); + rejectEmptyStringIfPresent(opts.agentId, "agentId", fn); + rejectEmptyStringIfPresent(opts.sessionId, "sessionId", fn); + rejectEmptyStringIfPresent(opts.offeringId, "offeringId", fn); + rejectEmptyStringIfPresent(opts.schemaVersion, "schemaVersion", fn); + rejectEmptyStringIfPresent(opts.traceparent, "traceparent", fn); + rejectEmptyStringIfPresent(opts.tracestate, "tracestate", fn); + + if (opts.flags !== undefined) { + opts.flags.forEach((flag, i) => { + if (typeof flag !== "string" || flag.length === 0) { + throw new Error(`${fn}: \`flags[${i}]\` must be a non-empty string`); + } + }); + } + + const signatureStatus = opts.signatureStatus ?? "missing"; + const validatedStatus = SignatureStatus(signatureStatus); + if (validatedStatus instanceof type.errors) { + throw new Error( + `${fn}: \`signatureStatus\` is not a recognised SignatureStatus: ${validatedStatus.summary}`, + ); + } + + const date = normalizeDate(opts.date, "date", fn); + const messageId = opts.messageId ?? generateMessageId(opts.from); + const derivedInterchangeType = opts.interchangeType ?? opts.payload?.type; + + const headers: MessageHeaders = { from: opts.from, to, date, messageId }; + if (cc !== undefined) headers.cc = cc; + if (opts.subject !== undefined) headers.subject = opts.subject; + if (opts.inReplyTo !== undefined) headers.inReplyTo = opts.inReplyTo; + if (opts.references !== undefined) headers.references = opts.references; + if (opts.listId !== undefined) headers.listId = opts.listId; + if (derivedInterchangeType !== undefined) { + headers.interchangeType = derivedInterchangeType; + } + if (opts.correlationId !== undefined) { + headers.interchangeCorrelationId = opts.correlationId; + } + if (opts.tenantId !== undefined) headers.interchangeTenantId = opts.tenantId; + if (opts.agentId !== undefined) headers.interchangeAgentId = opts.agentId; + if (opts.sessionId !== undefined) { + headers.interchangeSessionId = opts.sessionId; + } + if (opts.offeringId !== undefined) { + headers.interchangeOfferingId = opts.offeringId; + } + if (opts.schemaVersion !== undefined) { + headers.interchangeSchemaVersion = opts.schemaVersion; + } + if (opts.traceparent !== undefined) headers.traceparent = opts.traceparent; + if (opts.tracestate !== undefined) headers.tracestate = opts.tracestate; + + if (opts.ref?.uid !== undefined) { + if ( + typeof opts.ref.uid !== "number" || + !Number.isInteger(opts.ref.uid) || + !Number.isFinite(opts.ref.uid) || + opts.ref.uid < 1 + ) { + throw new Error( + `${fn}: \`ref.uid\`, when provided, must be a positive integer (IMAP UID)`, + ); + } + } + const ref: MessageRef = { + uid: opts.ref?.uid ?? 1, + mailbox: opts.ref?.mailbox ?? "INBOX", + }; + if (typeof ref.mailbox !== "string" || ref.mailbox.length === 0) { + throw new Error( + `${fn}: \`ref.mailbox\`, when provided, must be a non-empty string`, + ); + } + + const result: InboundMessage = { + ref, + headers, + flags: opts.flags ?? [], + signatureStatus, + }; + if (opts.content !== undefined) result.content = opts.content; + if (opts.payload !== undefined) { + result.payload = { + type: opts.payload.type, + version: opts.payload.version ?? DEFAULT_PAYLOAD_VERSION, + body: opts.payload.body, + }; + } + if (opts.attachments !== undefined && opts.attachments.length > 0) { + result.attachments = opts.attachments; + } + + return result; +} + +// --------------------------------------------------------------------------- +// OutboundMessage builder +// --------------------------------------------------------------------------- + +export type CreateOutboundMessageOpts = { + to: string | string[]; + + /** Interchange payload type. Determines content vs payload semantics. */ + type: InterchangeType; + + /** Plain-text body. Mutually exclusive with `payload`. */ + content?: string; + + /** Structured JSON envelope body. Mutually exclusive with `content`. */ + payload?: Record; + + cc?: string | string[]; + subject?: string; + + /** Human-readable summary used as the text/plain part for structured types. */ + summary?: string; + + inReplyTo?: string; + references?: string[]; + correlationId?: string; + sessionId?: string; + tenantId?: string; + + attachments?: MessageAttachment[]; +}; + +export function createOutboundMessage( + opts: CreateOutboundMessageOpts, +): OutboundMessage { + const fn = "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); + if (opts.cc !== undefined) { + normalizeAndValidateAddressArray(opts.cc, "cc", fn); + } + + validateBodyExclusivity(opts.content, opts.payload, fn); + + if (isConversationType(opts.type)) { + if (opts.payload !== undefined) { + throw new Error( + `${fn}: conversation \`type\` ${opts.type} must use \`content\` instead of \`payload\``, + ); + } + if (opts.content === undefined) { + throw new Error( + `${fn}: conversation \`type\` ${opts.type} requires \`content\``, + ); + } + } else { + if (opts.content !== undefined) { + throw new Error( + `${fn}: non-conversation \`type\` ${opts.type} must use \`payload\` instead of \`content\``, + ); + } + if (opts.payload === undefined) { + throw new Error( + `${fn}: non-conversation \`type\` ${opts.type} requires \`payload\``, + ); + } + } + if (opts.payload !== undefined) { + validatePayloadBody(opts.payload, "payload", fn); + } + + if (opts.inReplyTo !== undefined) { + validateMessageId(opts.inReplyTo, "inReplyTo", fn); + } + if (opts.references !== undefined) { + if (opts.references.length === 0) { + throw new Error( + `${fn}: \`references\`, when provided, must contain at least one entry`, + ); + } + opts.references.forEach((ref, i) => { + validateMessageId(ref, `references[${i}]`, fn); + }); + } + rejectEmptyStringIfPresent(opts.content, "content", fn); + rejectEmptyStringIfPresent(opts.subject, "subject", fn); + rejectEmptyStringIfPresent(opts.summary, "summary", fn); + rejectEmptyStringIfPresent(opts.correlationId, "correlationId", fn); + rejectEmptyStringIfPresent(opts.sessionId, "sessionId", fn); + rejectEmptyStringIfPresent(opts.tenantId, "tenantId", fn); + + const result: OutboundMessage = { to: opts.to, type: opts.type }; + if (opts.cc !== undefined) result.cc = opts.cc; + if (opts.subject !== undefined) result.subject = opts.subject; + if (opts.content !== undefined) result.content = opts.content; + if (opts.payload !== undefined) result.payload = opts.payload; + if (opts.summary !== undefined) result.summary = opts.summary; + if (opts.attachments !== undefined && opts.attachments.length > 0) { + result.attachments = opts.attachments; + } + if (opts.inReplyTo !== undefined) result.inReplyTo = opts.inReplyTo; + if (opts.references !== undefined) result.references = opts.references; + if (opts.correlationId !== undefined) { + result.correlationId = opts.correlationId; + } + if (opts.sessionId !== undefined) result.sessionId = opts.sessionId; + if (opts.tenantId !== undefined) result.tenantId = opts.tenantId; + return result; +} + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +function rejectEmptyStringIfPresent( + value: string | undefined, + field: string, + fn: string, +): void { + if (value !== undefined && value.length === 0) { + throw new Error( + `${fn}: \`${field}\`, when provided, must be a non-empty string`, + ); + } +} + +function requireAddress(value: unknown, field: string, fn: string): void { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${fn}: \`${field}\` must be a non-empty string`); + } + if (!ADDRESS_RE.test(value)) { + throw new Error( + `${fn}: \`${field}\` must be an RFC 5322 address of the form \`local@domain\`; got: ${value}`, + ); + } +} + +function normalizeAndValidateAddressArray( + input: string | string[], + field: string, + fn: string, +): string[] { + if (typeof input === "string") { + requireAddress(input, field, fn); + return [input]; + } + if (!Array.isArray(input) || input.length === 0) { + throw new Error( + `${fn}: \`${field}\` must contain at least one recipient address`, + ); + } + input.forEach((entry, i) => { + requireAddress(entry, `${field}[${i}]`, fn); + }); + return input; +} + +function validatePayloadBody(value: unknown, field: string, fn: string): void { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error( + `${fn}: \`${field}\` must be a plain object (got ${ + value === null ? "null" : Array.isArray(value) ? "array" : typeof value + })`, + ); + } +} + +function isConversationType(t: InterchangeType): boolean { + return t.startsWith(CONVERSATION_TYPE_PREFIX); +} + +function validateInterchangeType( + value: unknown, + field: string, + fn: string, +): void { + const validated = InterchangeType(value); + if (validated instanceof type.errors) { + throw new Error( + `${fn}: \`${field}\` is not a valid InterchangeType: ${validated.summary}`, + ); + } +} + +function validateMessageId(value: string, field: string, fn: string): void { + if (!MESSAGE_ID_RE.test(value)) { + throw new Error( + `${fn}: \`${field}\` must be an RFC 2822 message identifier of the form \`\`; got: ${value}`, + ); + } +} + +/** + * Non-throwing predicate for the RFC 2822 message-identifier form ``. + * A caller forwarding a `messageId`/`inReplyTo`/`references` value into + * `createInboundMessage` (which rejects a malformed identifier) uses this to + * decide whether the value is safe to forward: inbound mail can carry a + * headerless-derived (sha256) or otherwise malformed Message-Id that is a + * valid claim-check key but not a valid RFC identifier. + */ +export function isMessageId(value: string): boolean { + return MESSAGE_ID_RE.test(value); +} + +function normalizeDate( + input: Date | string | undefined, + field: string, + fn: string, +): string { + if (input === undefined) return new Date().toISOString(); + if (input instanceof Date) { + if (Number.isNaN(input.getTime())) { + throw new Error(`${fn}: \`${field}\` is an Invalid Date`); + } + return input.toISOString(); + } + if (typeof input !== "string" || input.length === 0) { + throw new Error( + `${fn}: \`${field}\`, when provided, must be a Date or a non-empty string`, + ); + } + const parsed = new Date(input); + if (Number.isNaN(parsed.getTime())) { + throw new Error( + `${fn}: \`${field}\` is not a parseable date string: ${input}`, + ); + } + return parsed.toISOString(); +} + +function validateBodyExclusivity( + content: unknown, + payload: unknown, + fn: string, +): void { + if (content !== undefined && payload !== undefined) { + throw new Error( + `${fn}: \`content\` and \`payload\` are mutually exclusive; provide at most one`, + ); + } +} diff --git a/vendor/intx-mime/src/mail-decode.test.ts b/vendor/intx-mime/src/mail-decode.test.ts new file mode 100644 index 000000000..c297c2914 --- /dev/null +++ b/vendor/intx-mime/src/mail-decode.test.ts @@ -0,0 +1,218 @@ +import { describe, test, expect } from "bun:test"; +import type { MailPart, MessageAttachment } from "@intx/types/runtime"; +import { isMail } from "@intx/types/runtime"; + +import { assembleSignedContent, assembleMessage, decodeMail } from "./index"; +import type { MessageHeaders } from "./index"; + +function rawBytes(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +function headers(overrides: Partial = {}): MessageHeaders { + return { + from: '"Alice" ', + to: ["run@deployment.example.com"], + cc: undefined, + date: new Date("2026-01-02T03:04:05Z"), + messageId: "", + subject: "hello there", + inReplyTo: undefined, + references: undefined, + mimeVersion: "1.0", + interchangeType: "conversation.message", + interchangeCorrelationId: undefined, + interchangeTenantId: undefined, + interchangeAgentId: undefined, + interchangeSessionId: undefined, + interchangeOfferingId: undefined, + interchangeSchemaVersion: undefined, + traceparent: undefined, + tracestate: undefined, + ...overrides, + }; +} + +function attachment( + name: string, + contentType: string, + data: string, +): MessageAttachment { + return { name, contentType, data: new TextEncoder().encode(data) }; +} + +function signedConversation( + text: string, + attachments: MessageAttachment[] = [], +): Uint8Array { + const signedContent = assembleSignedContent({ + kind: "conversation", + text, + ...(attachments.length > 0 ? { attachments } : {}), + }); + const signature = new TextEncoder().encode("placeholder-signature"); + return assembleMessage(headers(), signedContent, signature); +} + +describe("decodeMail", () => { + test("decodes headers (typed + raw) and the text part of a plain message", () => { + const mail = decodeMail(signedConversation("hello world")); + + expect(mail.headers.from).toBe('"Alice" '); + expect(mail.headers.subject).toBe("hello there"); + expect(mail.headers.messageId).toBe(""); + expect(mail.headers.interchangeType).toBe("conversation.message"); + + // Full raw header map: every header present, lowercased, multi-value safe. + expect(mail.rawHeaders["subject"]).toEqual(["hello there"]); + expect(mail.rawHeaders["message-id"]).toEqual([ + "", + ]); + expect(mail.rawHeaders["content-type"]?.[0]).toContain("multipart/signed"); + + // One decoded leaf part: the text body. The PGP signature part is dropped. + expect(mail.parts).toHaveLength(1); + expect(mail.parts[0]?.contentType).toBe("text/plain"); + expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe( + "hello world", + ); + }); + + test("decodes every part of a message with attachments, no data loss", () => { + const mail = decodeMail( + signedConversation("see attached", [ + attachment("photo.png", "image/png", "png-bytes"), + attachment("data.json", "application/json", '{"k":1}'), + ]), + ); + + // Text part + 2 attachment parts; signature excluded. + expect(mail.parts).toHaveLength(3); + + const text = mail.parts.find((p) => p.contentType === "text/plain"); + expect(new TextDecoder().decode(text?.content)).toBe("see attached"); + + const png = mail.parts.find((p) => p.contentType === "image/png"); + expect(png?.filename).toBe("photo.png"); + expect(png?.disposition).toBe("attachment"); + expect(new TextDecoder().decode(png?.content)).toBe("png-bytes"); + + const json = mail.parts.find((p) => p.contentType === "application/json"); + expect(json?.filename).toBe("data.json"); + expect(new TextDecoder().decode(json?.content)).toBe('{"k":1}'); + }); + + test("decodes an attachments-only message with empty text", () => { + const mail = decodeMail( + signedConversation("", [attachment("a.mp3", "audio/mpeg", "audiobytes")]), + ); + const audio = mail.parts.find((p) => p.contentType === "audio/mpeg"); + expect(audio?.filename).toBe("a.mp3"); + expect(new TextDecoder().decode(audio?.content)).toBe("audiobytes"); + }); + + test("keeps repeated headers and unfolds continuation lines", () => { + const mail = decodeMail( + rawBytes( + "From: a@b\r\n" + + "Received: from one\r\n" + + "Received: from two\r\n" + + "Subject: folded\r\n subject line\r\n" + + "Content-Type: text/plain\r\n\r\nbody", + ), + ); + // Every occurrence of a repeated header is preserved, in order. + expect(mail.rawHeaders["received"]).toEqual(["from one", "from two"]); + // A folded value is unfolded onto its header. + expect(mail.rawHeaders["subject"]).toEqual(["folded subject line"]); + }); + + test("undoes base64 transfer-encoding on a leaf part", () => { + const mail = decodeMail( + rawBytes( + "Content-Type: text/plain\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\naGVsbG8gd29ybGQ=", + ), + ); + expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe( + "hello world", + ); + }); + + test("undoes quoted-printable transfer-encoding on a leaf part", () => { + const mail = decodeMail( + rawBytes( + "Content-Type: text/plain\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\nhello=20world", + ), + ); + expect(new TextDecoder().decode(mail.parts[0]?.content)).toBe( + "hello world", + ); + }); + + test("does not truncate the final header when there is no body separator", () => { + // A message that is all headers and no blank-line separator: the raw + // header map must carry the final header value in full, not chopped. + const mail = decodeMail(rawBytes("From: alice@example.com")); + expect(mail.rawHeaders["from"]).toEqual(["alice@example.com"]); + expect(mail.headers.from).toBe("alice@example.com"); + }); + + test("throws on a multipart part with no boundary rather than dropping it", () => { + expect(() => + decodeMail( + rawBytes("Content-Type: multipart/mixed\r\n\r\nlost inner content"), + ), + ).toThrow(/no boundary/); + }); +}); + +describe("isMail", () => { + function validMail(): Record { + const parts: MailPart[] = [ + { contentType: "text/plain", ref: "mail-part:///r/m/0-body", text: "hi" }, + ]; + return { headers: { from: "a@b", to: ["c@d"] }, rawHeaders: {}, parts }; + } + + test("accepts a minimal valid Mail shape", () => { + expect(isMail(validMail())).toBe(true); + }); + + 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. + expect( + isMail({ + ...validMail(), + parts: [{ contentType: "text/plain", content: new Uint8Array() }], + }), + ).toBe(false); + }); + + test("rejects an undeclared key at the top level or on a part", () => { + expect(isMail({ ...validMail(), extra: 1 })).toBe(false); + expect( + isMail({ + ...validMail(), + parts: [{ contentType: "text/plain", ref: "r", nope: 1 }], + }), + ).toBe(false); + }); + + test("rejects a non-array parts and a bad disposition literal", () => { + expect(isMail({ ...validMail(), parts: {} })).toBe(false); + expect( + isMail({ + ...validMail(), + parts: [{ contentType: "text/plain", ref: "r", disposition: "bogus" }], + }), + ).toBe(false); + }); + + test("rejects headers missing the from/to a consumer dereferences", () => { + expect(isMail({ ...validMail(), headers: {} })).toBe(false); + expect(isMail({ ...validMail(), headers: { from: "a@b" } })).toBe(false); + }); +}); diff --git a/vendor/intx-mime/src/mime.ts b/vendor/intx-mime/src/mime.ts new file mode 100644 index 000000000..adb9021f0 --- /dev/null +++ b/vendor/intx-mime/src/mime.ts @@ -0,0 +1,1334 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME parser uses bounded array access throughout */ +/** + * MIME byte construction and parsing for Interchange messages. + * + * Implements exactly two message shapes per MESSAGE.md: + * 1. Conversation: multipart/mixed (text/plain plus zero or more + * attachment parts) in multipart/signed + * 2. Structured: application/vnd.interchange+json in multipart/mixed in multipart/signed + * + * Produces real RFC 2822 / RFC 2046 / RFC 3156 bytes. The signed content + * part is produced in MIME canonical form (CRLF line endings) so PGP/MIME + * verification operates on the same bytes regardless of platform. + * + * RFC references verified: + * - RFC 2822 §2.1.1: lines MUST NOT exceed 998 chars; recommended 78 + * - RFC 2046 §5.1.1: boundary MUST be <= 70 chars; CRLF before each boundary + * - RFC 3156 §5: multipart/signed; protocol="application/pgp-signature"; + * micalg=pgp-sha512; first part = signed content; second part = signature + * - Message-IDs: — valid per RFC 2822 §3.6.4 (dot-atom local-part) + */ + +import { type } from "arktype"; +import { base64Decode, base64Encode } from "@intx/types"; +import type { + MessageAttachment, + MessageHeaders as ParsedMessageHeaders, + MessagePart, +} from "@intx/types/runtime"; +import { InterchangeType } from "@intx/types/runtime"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type MessageHeaders = { + from: string; + to: string[]; + cc: string[] | undefined; + date: Date; + messageId: string; + subject: string | undefined; + inReplyTo: string | undefined; + references: string[] | undefined; + mimeVersion: "1.0"; + interchangeType: string | undefined; + interchangeCorrelationId: string | undefined; + interchangeTenantId: string | undefined; + interchangeAgentId: string | undefined; + interchangeSessionId: string | undefined; + interchangeOfferingId: string | undefined; + interchangeSchemaVersion: string | undefined; + traceparent: string | undefined; + tracestate: string | undefined; +}; + +export type ConversationContent = { + kind: "conversation"; + text: string; + attachments?: MessageAttachment[]; +}; + +export type StructuredContent = { + kind: "structured"; + json: Record; + summary?: string; +}; + +export type MimeAssemblyInput = { + headers: MessageHeaders; + content: ConversationContent | StructuredContent; +}; + +export type ParsedMimePart = { + contentType: string; + headers: Map; + body: Uint8Array; +}; + +export type ParsedMimeMessage = { + headers: Map; + parts: ParsedMimePart[]; +}; + +// --------------------------------------------------------------------------- +// JMAP Email types (RFC 8621) +// --------------------------------------------------------------------------- + +export type JMAPAddress = { + name: string | null; + email: string; +}; + +export type JMAPBodyValue = { + value: string; + isEncodingProblem: boolean; +}; + +export type JMAPBodyPart = { + partId: string; + type: string; +}; + +export type JMAPAttachment = { + blobId: string; + name: string | null; + type: string; + size: number; +}; + +export type JMAPEmail = { + from: JMAPAddress[]; + to: JMAPAddress[]; + subject: string | null; + sentAt: string | null; + bodyValues: Record; + textBody: JMAPBodyPart[]; + htmlBody: JMAPBodyPart[]; + attachments: JMAPAttachment[]; + headers: Record; +}; + +// --------------------------------------------------------------------------- +// Message-ID generation +// --------------------------------------------------------------------------- + +export function generateMessageId(address: string): string { + const domain = address.includes("@") ? address.split("@")[1]! : "local"; + const uuid = crypto.randomUUID(); + return `<${uuid}@${domain}>`; +} + +// --------------------------------------------------------------------------- +// Address normalization +// --------------------------------------------------------------------------- + +/** + * 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): + * `"Display Name" ` → `user@host` + * `Display Name ` → `user@host` + * `` → `user@host` + * `user@host` → `user@host` + * ` User@Host ` → `user@host` + * + * Rejected (throws) inputs: + * - empty or whitespace-only + * - input with no `@` + * - input that produces an empty local-part or domain + * - quoted local-parts (e.g. `"a@b"@host`) — technically valid per RFC + * 5321 §4.1.2 but rare in practice; the simple split below would + * misinterpret the inner `@`, so we refuse rather than guess + * - content after the closing `>` in an angle-bracketed form + * (e.g. `Name (comment)`) — would silently fall through to a + * misparsed bare-form attempt, so we refuse instead + * + * Per RFC 5321 §2.4 the local-part is technically case-sensitive, but no + * production system honors that; matching case-insensitively is the + * correct call for routing and identity checks. + */ +export function extractAddrSpec(addressLine: string): string { + const trimmed = addressLine.trim(); + if (trimmed === "") { + throw new Error("extractAddrSpec: address is empty"); + } + + 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)` + // is refused rather than re-parsed as a bare addr-spec. + if (!trimmed.endsWith(">")) { + throw new Error( + `extractAddrSpec: trailing content after '>' in ${JSON.stringify(addressLine)}`, + ); + } + candidate = trimmed.slice(angleOpen + 1, -1).trim(); + } else { + candidate = trimmed; + } + + // Reject quoted local-parts: the parser below splits on the first `@`, + // which would corrupt a quoted form whose local-part contains `@`. + if (candidate.includes('"')) { + throw new Error( + `extractAddrSpec: quoted local-parts are not supported: ${JSON.stringify(addressLine)}`, + ); + } + + const atIndex = candidate.indexOf("@"); + if (atIndex === -1) { + throw new Error( + `extractAddrSpec: address has no '@': ${JSON.stringify(addressLine)}`, + ); + } + + // Reject any further `@` in the candidate — a well-formed addr-spec + // has exactly one. Multiple `@` is either a quoted form (rejected + // above) or simply malformed. + if (candidate.indexOf("@", atIndex + 1) !== -1) { + throw new Error( + `extractAddrSpec: multiple '@' in ${JSON.stringify(addressLine)}`, + ); + } + + const local = candidate.slice(0, atIndex); + const domain = candidate.slice(atIndex + 1); + if (local === "" || domain === "") { + throw new Error( + `extractAddrSpec: empty local-part or domain in ${JSON.stringify(addressLine)}`, + ); + } + + return `${local.toLowerCase()}@${domain.toLowerCase()}`; +} + +// --------------------------------------------------------------------------- +// RFC 2822 date formatting +// --------------------------------------------------------------------------- + +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; + +export function formatRFC2822Date(date: Date): string { + const day = DAYS[date.getUTCDay()]!; + const d = String(date.getUTCDate()).padStart(2, "0"); + const mon = MONTHS[date.getUTCMonth()]!; + const year = date.getUTCFullYear(); + const h = String(date.getUTCHours()).padStart(2, "0"); + const m = String(date.getUTCMinutes()).padStart(2, "0"); + const s = String(date.getUTCSeconds()).padStart(2, "0"); + return `${day}, ${d} ${mon} ${year} ${h}:${m}:${s} +0000`; +} + +// --------------------------------------------------------------------------- +// Boundary generation +// --------------------------------------------------------------------------- + +function generateBoundary(): string { + const bytes = new Uint8Array(18); + crypto.getRandomValues(bytes); + return ( + "----=_Part_" + + Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + ); +} + +// --------------------------------------------------------------------------- +// Header serialization (RFC 2822) +// --------------------------------------------------------------------------- + +const CRLF = "\r\n"; + +function hdr(name: string, value: string): string { + return `${name}: ${value}${CRLF}`; +} + +function serializeMessageHeaders( + h: MessageHeaders, + contentType: string, +): string { + let out = ""; + out += hdr("From", h.from); + out += hdr("To", Array.isArray(h.to) ? h.to.join(", ") : (h.to as string)); + 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); + } + if (h.inReplyTo !== undefined) { + out += hdr("In-Reply-To", h.inReplyTo); + } + if (h.references !== undefined && h.references.length > 0) { + out += hdr("References", h.references.join(" ")); + } + out += hdr("MIME-Version", "1.0"); + out += hdr("Content-Type", contentType); + + // Interchange headers + if (h.interchangeType !== undefined) { + out += hdr("Interchange-Type", h.interchangeType); + } + if (h.interchangeCorrelationId !== undefined) { + out += hdr("Interchange-Correlation-ID", h.interchangeCorrelationId); + } + if (h.interchangeTenantId !== undefined) { + out += hdr("Interchange-Tenant-ID", h.interchangeTenantId); + } + if (h.interchangeAgentId !== undefined) { + out += hdr("Interchange-Agent-ID", h.interchangeAgentId); + } + if (h.interchangeSessionId !== undefined) { + out += hdr("Interchange-Session-ID", h.interchangeSessionId); + } + if (h.interchangeOfferingId !== undefined) { + out += hdr("Interchange-Offering-ID", h.interchangeOfferingId); + } + if (h.interchangeSchemaVersion !== undefined) { + out += hdr("Interchange-Schema-Version", h.interchangeSchemaVersion); + } + if (h.traceparent !== undefined) { + out += hdr("traceparent", h.traceparent); + } + if (h.tracestate !== undefined) { + out += hdr("tracestate", h.tracestate); + } + + return out; +} + +// --------------------------------------------------------------------------- +// 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 { + if (/[\r\n]/.test(value)) { + throw new Error( + `${field} must not contain CR or LF: ${JSON.stringify(value)}`, + ); + } + if (value.includes('"')) { + throw new Error( + `${field} must not contain a double quote: ${JSON.stringify(value)}`, + ); + } +} + +/** + * Encode bytes as base64, wrapped at 76 columns per RFC 2045. Returns the + * empty string for empty input. + */ +function base64Lines(bytes: Uint8Array): string { + const b64 = base64Encode(bytes); + const lines: string[] = []; + for (let i = 0; i < b64.length; i += 76) { + lines.push(b64.slice(i, i + 76)); + } + return lines.join(CRLF); +} + +/** + * Assemble the signed content for a conversation message. + * + * The shape is always multipart/mixed: one text/plain part (BODY[1.1]) + * followed by zero or more binary attachment parts (BODY[1.2..N]). The + * shape is unconditional — there is no bare text/plain branch — so the + * writer, the parser, and the signed-bytes contract have one form each. + * + * This is the exact bytes that will be hashed for the PGP/MIME signature. + */ +function assembleConversationSignedPart( + text: string, + attachments: readonly MessageAttachment[] = [], +): 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); + + 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 += `${CRLF}`; + body += `${canonical}${CRLF}`; + + // Attachment parts (BODY[1.2..N]) + for (const att of attachments) { + assertHeaderSafe(att.contentType, "attachment contentType"); + assertHeaderSafe(att.name, "attachment name"); + body += `--${boundary}${CRLF}`; + body += `Content-Type: ${att.contentType}${CRLF}`; + body += `Content-Transfer-Encoding: base64${CRLF}`; + body += `Content-Disposition: attachment; filename="${att.name}"${CRLF}`; + body += `${CRLF}`; + body += `${base64Lines(att.data)}${CRLF}`; + } + + body += `--${boundary}--${CRLF}`; + return new TextEncoder().encode(body); +} + +/** + * Assemble the signed content for a structured message (multipart/mixed). + * + * This is the exact bytes that will be hashed for the PGP/MIME signature. + */ +function assembleStructuredSignedPart( + json: Record, + summary?: string, +): Uint8Array { + const boundary = generateBoundary(); + const jsonStr = 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 += `${CRLF}`; + body += `${jsonStr}${CRLF}`; + + // Optional human-readable summary + if (summary !== undefined) { + body += `--${boundary}${CRLF}`; + body += `Content-Type: text/plain; charset=utf-8${CRLF}`; + body += `Content-Transfer-Encoding: 7bit${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 += `--${boundary}--${CRLF}`; + return new TextEncoder().encode(body); +} + +/** + * Wrap content part and PGP signature into multipart/signed per RFC 3156. + * + * RFC 3156 §5: The multipart/signed body MUST consist of exactly two parts. + * The first part contains the signed data. The second part contains the + * detached PGP signature in application/pgp-signature. + * + * The boundary delimiter lines use CRLF as required by RFC 2046. + */ +function wrapInMultipartSigned( + signedContentBytes: Uint8Array, + signatureBytes: Uint8Array, + boundary: string, +): Uint8Array { + const signedContent = new TextDecoder().decode(signedContentBytes); + const signature = new TextDecoder().decode(signatureBytes); + + const enc = new TextEncoder(); + + // Per RFC 2046: boundary delimiter = "--" + boundary parameter. + // The CRLF preceding the boundary belongs to the boundary, not the part. + // Each part is preceded by: CRLF + "--" + boundary + CRLF + // The closing delimiter: CRLF + "--" + boundary + "--" + CRLF + const body = + `--${boundary}${CRLF}` + + `${signedContent}` + + `${CRLF}--${boundary}${CRLF}` + + `Content-Type: application/pgp-signature${CRLF}` + + `${CRLF}` + + `${signature}${CRLF}` + + `--${boundary}--${CRLF}`; + + return enc.encode(body); +} + +// --------------------------------------------------------------------------- +// Full message assembly +// --------------------------------------------------------------------------- + +/** + * Assemble a complete RFC 2822 message from headers, content, and signature + * bytes. Returns the raw message bytes for storage. + * + * The signature bytes must be produced by signing the signed content part + * bytes (the result of assembleSignedContentPart below). + */ +export function assembleMessage( + headers: MessageHeaders, + signedContentBytes: Uint8Array, + signatureBytes: Uint8Array, +): Uint8Array { + const outerBoundary = generateBoundary(); + + const contentType = + `multipart/signed; protocol="application/pgp-signature"; ` + + `micalg=pgp-sha512; boundary="${outerBoundary}"`; + + const headerSection = serializeMessageHeaders(headers, contentType); + const bodyBytes = wrapInMultipartSigned( + signedContentBytes, + signatureBytes, + outerBoundary, + ); + + const enc = new TextEncoder(); + const headerBytes = enc.encode(headerSection + CRLF); + + const result = new Uint8Array(headerBytes.length + bodyBytes.length); + result.set(headerBytes, 0); + result.set(bodyBytes, headerBytes.length); + return result; +} + +/** + * Build the signed content bytes for a message. These exact bytes are + * what the CryptoProvider signs. The transport calls this, then signs, + * then calls assembleMessage with both. + */ +export function assembleSignedContent( + content: ConversationContent | StructuredContent, +): Uint8Array { + if (content.kind === "conversation") { + return assembleConversationSignedPart(content.text, content.attachments); + } + return assembleStructuredSignedPart(content.json, content.summary); +} + +// --------------------------------------------------------------------------- +// MIME parsing (for fetchHeaders, fetchStructure, fetchPart, fetchFull) +// --------------------------------------------------------------------------- + +const CRLF_CRLF = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]); +const LF_LF = new Uint8Array([0x0a, 0x0a]); + +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; + } + return i; + } + return -1; +} + +/** + * 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. + */ +export function parseHeaderSection(raw: Uint8Array): { + headers: Map; + bodyOffset: number; + headerEnd: number; +} { + 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); + + 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; + } + + const headerText = new TextDecoder("utf-8", { fatal: false }).decode( + raw.subarray(0, headerEnd), + ); + parseHeaders(headerText, headers); + + return { headers, bodyOffset, headerEnd }; +} + +function parseHeaders(headerSection: string, out: Map): 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/); + for (const line of lines) { + if (line.trim() === "") continue; + const colon = line.indexOf(":"); + if (colon === -1) continue; + const name = line.slice(0, colon).trim().toLowerCase(); + const value = line.slice(colon + 1).trim(); + // For repeated headers (like Received), keep the first value. + if (!out.has(name)) { + out.set(name, value); + } + } +} + +/** + * Extract the boundary parameter from a Content-Type header value. + */ +export function extractBoundary(contentTypeValue: string): string | undefined { + const match = + contentTypeValue.match(/boundary="([^"]+)"/i) ?? + contentTypeValue.match(/boundary=([^\s;]+)/i); + return match?.[1]; +} + +/** + * Parse a multipart body into individual parts. + * + * Each part is returned as raw bytes (headers + blank line + body) for + * further parsing. + */ +export function parseMultipart( + body: Uint8Array, + boundary: string, +): Uint8Array[] { + const text = new TextDecoder("utf-8", { fatal: false }).decode(body); + const delimiter = `--${boundary}`; + const parts: Uint8Array[] = []; + const enc = new TextEncoder(); + + let pos = 0; + while (pos < text.length) { + // Find next delimiter. + const delimIdx = text.indexOf(delimiter, pos); + if (delimIdx === -1) break; + + // Check if it's the closing delimiter. + const afterDelim = delimIdx + delimiter.length; + if (text.slice(afterDelim, afterDelim + 2) === "--") break; + + // Skip past the delimiter line (to end of CRLF or LF). + let partStart = afterDelim; + if (text[partStart] === "\r") partStart++; + if (text[partStart] === "\n") partStart++; + + // Find the next delimiter to know where this part ends. + const nextDelimIdx = text.indexOf("\n" + delimiter, partStart); + if (nextDelimIdx === -1) break; + + // Part body excludes the trailing CRLF before the next boundary. + let partEnd = nextDelimIdx; + // Account for the \n we searched for. + // We want to include only up to (but not including) the CRLF before "--boundary". + // nextDelimIdx points to the \n before the delimiter. The part ends before + // the preceding \r\n (or just \n). + if (partEnd > partStart && text[partEnd - 1] === "\r") { + partEnd--; + } + + const partText = text.slice(partStart, partEnd); + parts.push(enc.encode(partText)); + + pos = nextDelimIdx + 1; + } + + return parts; +} + +/** + * Parse a single MIME part into its headers and body. + */ +export function parseMimePart(partBytes: Uint8Array): ParsedMimePart { + const { headers, bodyOffset } = parseHeaderSection(partBytes); + const contentType = headers.get("content-type") ?? "application/octet-stream"; + const body = partBytes.slice(bodyOffset); + return { contentType, headers, body }; +} + +/** + * Extract a MIME part by dot-separated path from a multipart/signed message. + * + * Path "1" returns the signed content part (text/plain or multipart/mixed). + * Path "1.1" returns the first sub-part of the signed content (JSON payload). + * Path "2" returns the application/pgp-signature part. + * + * This follows IMAP FETCH section specifier semantics (RFC 9051). + */ +export function extractPartByPath( + raw: Uint8Array, + partPath: string, +): Uint8Array { + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = headers.get("content-type") ?? ""; + + const steps = partPath.split(".").map((s) => { + const n = parseInt(s, 10); + if (isNaN(n) || n < 1) { + throw new Error(`Invalid part path segment: "${s}"`); + } + return n; + }); + + return walkParts(body, contentType, steps, 0); +} + +function walkParts( + body: Uint8Array, + contentType: string, + steps: number[], + depth: number, +): Uint8Array { + const step = steps[depth]; + if (step === undefined) { + throw new Error("Part path has no more segments"); + } + + if (!contentType.toLowerCase().startsWith("multipart/")) { + throw new Error( + `Cannot index into non-multipart content type: ${contentType}`, + ); + } + + const boundary = extractBoundary(contentType); + if (boundary === undefined) { + throw new Error(`No boundary found in Content-Type: ${contentType}`); + } + + const parts = parseMultipart(body, boundary); + if (step > parts.length) { + throw new Error(`Part ${step} does not exist (only ${parts.length} parts)`); + } + + const partBytes = parts[step - 1]!; + + if (depth + 1 === steps.length) { + return partBytes; + } + + // Need to descend further. + const part = parseMimePart(partBytes); + return walkParts(part.body, part.contentType, steps, depth + 1); +} + +// --------------------------------------------------------------------------- +// JMAP Email parsing +// --------------------------------------------------------------------------- + +/** + * Parse a RFC 2822 address value into structured JMAP address objects. + * + * Handles both "Display Name" and bare email@example.com + * forms, as well as comma-separated address lists. + */ +function parseAddressList(value: string): JMAPAddress[] { + const results: JMAPAddress[] = []; + // Split on commas that are not inside quoted strings or angle brackets. + // We handle the two common forms: + // 1. "Display Name" + // 2. Display Name + // 3. + // 4. email + const segments = splitAddressList(value); + for (const segment of segments) { + const addr = parseOneAddress(segment.trim()); + if (addr !== null) { + results.push(addr); + } + } + return results; +} + +function splitAddressList(value: string): string[] { + const segments: string[] = []; + let current = ""; + let depth = 0; + let inQuote = false; + + for (const ch of value) { + if (ch === '"' && !inQuote) { + inQuote = true; + current += ch; + } else if (ch === '"' && inQuote) { + inQuote = false; + current += ch; + } else if (ch === "<" && !inQuote) { + depth++; + current += ch; + } else if (ch === ">" && !inQuote) { + depth--; + current += ch; + } else if (ch === "," && depth === 0 && !inQuote) { + segments.push(current); + current = ""; + } else { + current += ch; + } + } + if (current.trim() !== "") { + segments.push(current); + } + return segments; +} + +function parseOneAddress(segment: string): JMAPAddress | null { + if (segment === "") return null; + + // "Display Name" or Display Name + const angleMatch = segment.match(/^(.*?)<([^>]+)>\s*$/); + if (angleMatch !== null) { + const rawName = angleMatch[1]!.trim(); + const email = angleMatch[2]!.trim(); + // Strip surrounding quotes from display name if present + const name = + rawName === "" ? null : rawName.replace(/^"(.*)"$/, "$1").trim() || null; + return { name, email }; + } + + // Bare email address + const bare = segment.trim(); + if (bare !== "") { + return { name: null, email: bare }; + } + + return null; +} + +/** + * Parse the MIME Date header into an ISO 8601 string. + * + * Returns null if the header is missing or the value cannot be parsed. + */ +function parseDateHeader(value: string | undefined): string | null { + if (value === undefined) return null; + const date = new Date(value); + if (isNaN(date.getTime())) return null; + return date.toISOString(); +} + +/** + * Decode a MIME body part, handling Content-Transfer-Encoding. + */ +function decodeBodyBytes( + body: Uint8Array, + headers: Map, +): { value: string; isEncodingProblem: boolean } { + const cte = (headers.get("content-transfer-encoding") ?? "7bit") + .trim() + .toLowerCase(); + + if (cte === "base64") { + try { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + const cleaned = raw.replace(/\s+/g, ""); + const binaryStr = atob(cleaned); + return { value: binaryStr, isEncodingProblem: false }; + } catch { + return { + value: new TextDecoder("utf-8", { fatal: false }).decode(body), + isEncodingProblem: true, + }; + } + } + + if (cte === "quoted-printable") { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + return { value: decodeQuotedPrintable(raw), isEncodingProblem: false }; + } + + // 7bit, 8bit, binary — decode as UTF-8 + return { + value: new TextDecoder("utf-8", { fatal: false }).decode(body), + isEncodingProblem: false, + }; +} + +function decodeQuotedPrintable(text: string): string { + return text + .replace(/=\r\n/g, "") + .replace(/=\n/g, "") + .replace(/=([0-9A-Fa-f]{2})/g, (_match, hex: string) => + String.fromCharCode(parseInt(hex, 16)), + ); +} + +/** + * Determine whether a MIME part is an attachment based on Content-Disposition + * and content type. + */ +function isAttachmentPart( + contentType: string, + headers: Map, +): boolean { + const disposition = headers.get("content-disposition") ?? ""; + if (disposition.toLowerCase().startsWith("attachment")) return true; + + const ct = contentType.toLowerCase().split(";")[0]!.trim(); + if (ct === "text/plain" || ct === "text/html") return false; + + // Non-text types are treated as attachments unless they are multipart. + if (ct.startsWith("multipart/")) return false; + + return true; +} + +function extractContentTypeMime(contentType: string): string { + return contentType.split(";")[0]!.trim().toLowerCase(); +} + +function extractFilename(headers: Map): string | null { + const disposition = headers.get("content-disposition") ?? ""; + const nameMatch = + disposition.match(/filename="([^"]+)"/i) ?? + disposition.match(/filename=([^\s;]+)/i); + if (nameMatch !== null) return nameMatch[1]!; + + const ct = headers.get("content-type") ?? ""; + const ctNameMatch = + ct.match(/name="([^"]+)"/i) ?? ct.match(/name=([^\s;]+)/i); + if (ctNameMatch !== null) return ctNameMatch[1]!; + + return null; +} + +type WalkContext = { + mailId: string; + bodyValues: Record; + textBody: JMAPBodyPart[]; + htmlBody: JMAPBodyPart[]; + attachments: JMAPAttachment[]; +}; + +/** + * Recursively walk MIME parts, populating body values and attachment lists. + * + * partPath uses IMAP-style dot-separated numbering (e.g., "1", "1.1", "2.3"). + */ +function walkMimePart( + partBytes: Uint8Array, + partPath: string, + ctx: WalkContext, +): void { + const part = parseMimePart(partBytes); + const mime = extractContentTypeMime(part.contentType); + + if (mime.startsWith("multipart/")) { + const boundary = extractBoundary(part.contentType); + if (boundary === undefined) return; + const subParts = parseMultipart(part.body, boundary); + subParts.forEach((subPartBytes, idx) => { + walkMimePart(subPartBytes, `${partPath}.${idx + 1}`, ctx); + }); + return; + } + + if (isAttachmentPart(part.contentType, part.headers)) { + const blobId = `blob_${ctx.mailId}_${partPath}`; + ctx.attachments.push({ + blobId, + name: extractFilename(part.headers), + type: mime, + size: part.body.length, + }); + return; + } + + 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 }); + } +} + +/** + * Convert raw MIME bytes into a JMAP Email-shaped object. + * + * Handles text/plain, multipart/mixed, and multipart/signed message shapes. + * For multipart/signed (RFC 3156), the signed content part (part 1) is + * parsed for body and attachments. Signature verification is not performed. + * + * @param raw - Raw RFC 2822 message bytes + * @param mailId - Opaque mail record ID used to generate blob IDs + */ +export function parseMailToEmail(raw: Uint8Array, mailId: string): JMAPEmail { + const { headers: msgHeaders, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = msgHeaders.get("content-type") ?? "text/plain"; + const mime = extractContentTypeMime(contentType); + + const ctx: WalkContext = { + mailId, + bodyValues: {}, + textBody: [], + htmlBody: [], + attachments: [], + }; + + if (mime === "multipart/signed") { + // RFC 3156: part 1 is the signed content, part 2 is the signature. + // Parse the content part through to extract body and attachments. + const boundary = extractBoundary(contentType); + if (boundary !== undefined) { + const outerParts = parseMultipart(body, boundary); + const contentPart = outerParts[0]; + if (contentPart !== undefined) { + // The content part may itself be text/plain or multipart/mixed. + // We assign it path "1" and walk it. + walkMimePart(contentPart, "1", ctx); + } + } + } else if (mime.startsWith("multipart/")) { + const boundary = extractBoundary(contentType); + if (boundary !== undefined) { + const parts = parseMultipart(body, boundary); + parts.forEach((partBytes, idx) => { + walkMimePart(partBytes, `${idx + 1}`, ctx); + }); + } + } else { + // Single-part message (e.g. text/plain). + // Reconstruct minimal part bytes with content-type header so parseMimePart works. + 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); + walkMimePart(partBytes, "1", ctx); + } + + // Extract Interchange-specific headers. + const interchangeHeaders: Record = {}; + for (const [name, value] of msgHeaders) { + if (name.startsWith("interchange-")) { + interchangeHeaders[name] = value; + } + } + + return { + from: parseAddressList(msgHeaders.get("from") ?? ""), + to: parseAddressList(msgHeaders.get("to") ?? ""), + subject: msgHeaders.get("subject") ?? null, + sentAt: parseDateHeader(msgHeaders.get("date")), + bodyValues: ctx.bodyValues, + textBody: ctx.textBody, + htmlBody: ctx.htmlBody, + attachments: ctx.attachments, + headers: interchangeHeaders, + }; +} + +/** + * 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. + */ +function decodeAttachmentBytes( + body: Uint8Array, + headers: Map, +): Uint8Array { + const cte = (headers.get("content-transfer-encoding") ?? "7bit") + .trim() + .toLowerCase(); + + if (cte === "base64") { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + return base64Decode(raw.replace(/\s+/g, "")); + } + + 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; + } + + if (cte === "7bit" || cte === "8bit" || cte === "binary") { + return body; + } + + throw new Error( + `decodeAttachmentBytes: unsupported content-transfer-encoding "${cte}"`, + ); +} + +/** + * Extract conversation attachments from raw message bytes as + * `MessageAttachment[]` with decoded payloads. + * + * The conversation signed content is a multipart/mixed whose first part is + * the text body and whose remaining attachment parts (Content-Disposition: + * attachment) carry the binary payloads. Returns an empty array for any + * shape without attachment parts — a bare text/plain signed part, a + * non-multipart/signed message, or a multipart/mixed with only the text + * part — so callers can use it unconditionally. + * + * Counterpart to `assembleConversationSignedPart`: assemble then extract + * round-trips a `MessageAttachment[]`. + */ +export function extractAttachments(raw: Uint8Array): MessageAttachment[] { + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const mime = extractContentTypeMime(headers.get("content-type") ?? ""); + + if (mime !== "multipart/signed") return []; + const outerBoundary = extractBoundary(headers.get("content-type") ?? ""); + if (outerBoundary === undefined) return []; + + const contentPart = parseMultipart(body, outerBoundary)[0]; + if (contentPart === undefined) return []; + + const signed = parseMimePart(contentPart); + if (!extractContentTypeMime(signed.contentType).startsWith("multipart/")) { + return []; + } + const innerBoundary = extractBoundary(signed.contentType); + if (innerBoundary === undefined) return []; + + const attachments: MessageAttachment[] = []; + for (const subPartBytes of parseMultipart(signed.body, innerBoundary)) { + 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), + }); + } + return attachments; +} + +// --------------------------------------------------------------------------- +// Decoded-mail model (Mail / MessagePart) — lossless inbound decoding +// --------------------------------------------------------------------------- + +function isInterchangeType(s: string): s is InterchangeType { + return !(InterchangeType(s) instanceof type.errors); +} + +/** + * Build the typed, ergonomic `MessageHeaders` subset from a parsed header map. + * Optional fields are included only when present (exactOptionalPropertyTypes- + * safe). The full, lossless header set is carried separately as `rawHeaders`. + */ +export function buildMessageHeaders( + headers: Map, +): ParsedMessageHeaders { + const from = headers.get("from") ?? ""; + const toRaw = headers.get("to") ?? ""; + const to = toRaw + ? toRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const date = headers.get("date") ?? ""; + const messageId = headers.get("message-id") ?? ""; + + const result: ParsedMessageHeaders = { from, to, date, messageId }; + + const ccRaw = headers.get("cc"); + if (ccRaw !== undefined) { + const cc = ccRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (cc.length > 0) result.cc = cc; + } + + const refsRaw = headers.get("references"); + if (refsRaw !== undefined) { + const refs = refsRaw.split(/\s+/).filter(Boolean); + if (refs.length > 0) result.references = refs; + } + + const inReplyTo = headers.get("in-reply-to"); + if (inReplyTo !== undefined) result.inReplyTo = inReplyTo; + + const subject = headers.get("subject"); + if (subject !== undefined) result.subject = subject; + + const listId = headers.get("list-id"); + if (listId !== undefined) result.listId = listId; + + const rawType = headers.get("interchange-type"); + if (rawType !== undefined && isInterchangeType(rawType)) { + result.interchangeType = rawType; + } + + const corrId = headers.get("interchange-correlation-id"); + if (corrId !== undefined) result.interchangeCorrelationId = corrId; + + const tenantId = headers.get("interchange-tenant-id"); + if (tenantId !== undefined) result.interchangeTenantId = tenantId; + + const agentId = headers.get("interchange-agent-id"); + if (agentId !== undefined) result.interchangeAgentId = agentId; + + const sessionId = headers.get("interchange-session-id"); + if (sessionId !== undefined) result.interchangeSessionId = sessionId; + + const offeringId = headers.get("interchange-offering-id"); + if (offeringId !== undefined) result.interchangeOfferingId = offeringId; + + const schemaVersion = headers.get("interchange-schema-version"); + if (schemaVersion !== undefined) + result.interchangeSchemaVersion = schemaVersion; + + const traceparent = headers.get("traceparent"); + if (traceparent !== undefined) result.traceparent = traceparent; + + const tracestate = headers.get("tracestate"); + if (tracestate !== undefined) result.tracestate = tracestate; + + return result; +} + +/** + * Parse every header line in the message's header section into a raw, + * lossless map of lowercased name to its ordered values. Repeated headers + * (e.g. `Received`) keep all occurrences; folded continuation lines are + * unfolded onto the preceding header. Bounded to the header section via + * `headerEnd` so the whole message body is never decoded here. + */ +function parseRawHeaders( + raw: Uint8Array, + headerEnd: number, +): Record { + const text = new TextDecoder("utf-8", { fatal: false }).decode( + raw.subarray(0, headerEnd), + ); + const out: Record = {}; + 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()); + 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()}`; + continue; + } + const idx = line.indexOf(":"); + if (idx === -1) continue; + flush(); + current = { name: line.slice(0, idx), value: line.slice(idx + 1) }; + } + flush(); + return out; +} + +function parseDisposition( + headers: Map, +): "inline" | "attachment" | undefined { + const d = (headers.get("content-disposition") ?? "").trim().toLowerCase(); + if (d.startsWith("attachment")) return "attachment"; + if (d.startsWith("inline")) return "inline"; + return undefined; +} + +/** + * Recursively collect the decoded leaf parts of a MIME part. A multipart part + * recurses into its children; a leaf part is decoded (transfer-encoding undone) + * into a `MessagePart`. The PGP/MIME signature part is transport plumbing, not + * content, so it is skipped -- which unwraps the `multipart/signed` envelope + * (its two children are the signed content and the signature) for free. + */ +function collectLeafParts(partBytes: Uint8Array): MessagePart[] { + const part = parseMimePart(partBytes); + const mime = extractContentTypeMime(part.contentType); + if (mime === "application/pgp-signature") return []; + if (mime.startsWith("multipart/")) { + const boundary = extractBoundary(part.contentType); + // A multipart part with no boundary is undecodable: its children cannot + // be located. Silently returning [] would drop that content and break the + // lossless contract, so surface it as a decode failure the caller drops. + if (boundary === undefined) { + throw new Error( + `decodeMail: ${mime} part has no boundary parameter; cannot decode its children`, + ); + } + return parseMultipart(part.body, boundary).flatMap(collectLeafParts); + } + const result: MessagePart = { + contentType: mime, + content: decodeAttachmentBytes(part.body, part.headers), + }; + const filename = extractFilename(part.headers); + if (filename !== null) result.filename = filename; + const disposition = parseDisposition(part.headers); + if (disposition !== undefined) result.disposition = disposition; + return [result]; +} + +/** + * Decode a raw inbound MIME message into its lossless parts: the typed header + * subset, the full raw header map, and the flat list of decoded leaf parts + * (the PGP/MIME signature and multipart wrappers removed). This is the + * in-memory form; a caller commits each part's bytes to durable storage to + * produce a JSON-safe `Mail`. Reused across the standalone and deployed + * ingest paths so both see the same decoding. + */ +export function decodeMail(raw: Uint8Array): { + headers: ParsedMessageHeaders; + rawHeaders: Record; + parts: MessagePart[]; +} { + const { headers: singleMap, headerEnd } = parseHeaderSection(raw); + const rawHeaders = parseRawHeaders(raw, headerEnd); + const headers = buildMessageHeaders(singleMap); + const parts = collectLeafParts(raw); + return { headers, rawHeaders, parts }; +} diff --git a/vendor/intx-mime/src/pgp-sign.ts b/vendor/intx-mime/src/pgp-sign.ts new file mode 100644 index 000000000..bb89b480b --- /dev/null +++ b/vendor/intx-mime/src/pgp-sign.ts @@ -0,0 +1,29 @@ +/** + * PGP/MIME signing via CryptoProvider. + * + * createDetachedSignature in @intx/crypto signs with raw private key bytes, + * but callers that only hold a CryptoProvider (which does not expose the + * private key) need this variant. It delegates to the crypto package's + * signer-function primitive, handing it the provider's raw Ed25519 sign + * operation. The OpenPGP packet assembly lives entirely in @intx/crypto; + * this module only adapts a CryptoProvider into the signer the primitive + * expects. + */ + +import { createDetachedSignatureWithSigner } from "@intx/crypto"; +import type { CryptoProvider } from "@intx/types/runtime"; + +/** + * Produce a PGP/MIME detached signature using a CryptoProvider. + * + * Mirrors createDetachedSignature from @intx/crypto but accepts a + * CryptoProvider instead of raw private key bytes. + */ +export async function createDetachedSignatureFromProvider( + content: Uint8Array, + provider: CryptoProvider, +): Promise { + return createDetachedSignatureWithSigner(content, (input) => + provider.sign(input), + ); +} diff --git a/vendor/intx-mime/tsconfig.json b/vendor/intx-mime/tsconfig.json new file mode 100644 index 000000000..9e25e6ece --- /dev/null +++ b/vendor/intx-mime/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/vendor/intx-workflow-host/adapters/substrate-mailbox-store.test.ts b/vendor/intx-workflow-host/adapters/substrate-mailbox-store.test.ts new file mode 100644 index 000000000..c11e159c1 --- /dev/null +++ b/vendor/intx-workflow-host/adapters/substrate-mailbox-store.test.ts @@ -0,0 +1,586 @@ +import { describe, test, expect, afterAll, beforeAll } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { generateKeyPair, createEd25519Crypto } from "@intx/crypto"; +import type { Ed25519Crypto } from "@intx/crypto"; +import { + assembleMessage, + assembleSignedContent, + createDetachedSignatureFromProvider, + generateMessageId, +} from "@intx/mime"; +import type { MessageHeaders } from "@intx/mime"; +import type { CryptoProvider } from "@intx/types/runtime"; +import { createRepoStore, workflowRunAuthorize } from "@intx/hub-sessions"; +import type { + KindHandler, + Principal, + RepoId, + RepoStore, +} from "@intx/hub-sessions"; +import { executeSearch, fetchFull } from "@intx/mailbox"; +import type { StoredEnvelope } from "@intx/mailbox"; + +import { createSubstrateMailboxStore } from "./substrate-mailbox-store"; + +const REF = "refs/heads/main"; +const tempDirs: string[] = []; + +// The backing writes a top-level `mailbox/` subtree. The production +// workflow-run kind handler's push-time validation of that subtree is owned by +// the hub-replication layer, not this package; a permissive handler isolates +// this backing's persistence contract from that validation. +const permissiveHandler: KindHandler = { + kind: "workflow-run", + directoryPrefix: "workflow-runs", + validatePush: () => ({ ok: true }), + onRefUpdated: () => { + /* no-op */ + }, +}; + +let signingKey: Awaited>; + +beforeAll(async () => { + signingKey = await generateKeyPair(); +}); + +afterAll(async () => { + for (const d of tempDirs.splice(0)) { + await fs.promises.rm(d, { recursive: true, force: true }).catch(() => { + /* best effort */ + }); + } +}); + +async function makeTempDir(): Promise { + const d = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "substrate-mailbox-"), + ); + tempDirs.push(d); + return d; +} + +function makeStore(dataDir: string): RepoStore { + return createRepoStore({ + dataDir, + signingKey, + handlers: { "workflow-run": permissiveHandler }, + authorize: workflowRunAuthorize, + }); +} + +/** The put and delete path sets one `writeTreeDelta` call committed. */ +type DeltaRecord = { puts: string[]; deletes: string[] }; + +// Wrap a `RepoStore` so each `writeTreeDelta` records the exact paths its +// `computeDelta` produced, then delegates unchanged. The wrapper decorates the +// caller's `computeDelta` rather than re-deriving the delta, so it observes the +// same puts/deletes the store commits, computed against the real pinned parent. +function createRecordingSubstrate( + inner: RepoStore, + recorded: DeltaRecord[], +): RepoStore { + return { + ...inner, + writeTreeDelta(principal, repoId, ref, args) { + return inner.writeTreeDelta(principal, repoId, ref, { + ...args, + computeDelta: async (parentCommitSha, prior) => { + const delta = await args.computeDelta(parentCommitSha, prior); + recorded.push({ + puts: Object.keys(delta.puts), + deletes: [...delta.deletes], + }); + return delta; + }, + }); + }, + }; +} + +type Handles = { + dataDir: string; + repoId: RepoId; + principal: Principal; +}; + +async function makeHandles(deploymentId: string): Promise { + const dataDir = await makeTempDir(); + const repoId: RepoId = { kind: "workflow-run", id: deploymentId }; + // `Principal` exposes only `kind`; concrete fields are narrowed by kind + // handlers. Assign through an intermediate so the excess `anchorRunId` + // property is not rejected by the structural check. + const principalShape = { + kind: "workflow-process" as const, + anchorRunId: deploymentId, + }; + const principal: Principal = principalShape; + return { dataDir, repoId, principal }; +} + +function openStore(handles: Handles, dataDir?: string) { + return createSubstrateMailboxStore({ + substrate: makeStore(dataDir ?? handles.dataDir), + repoId: handles.repoId, + principal: handles.principal, + ref: REF, + }); +} + +function headersFor(fields: { + from: string; + to: string[]; + subject: string; + messageId: string; + date: Date; +}): MessageHeaders { + return { + from: fields.from, + to: fields.to, + cc: undefined, + date: fields.date, + messageId: fields.messageId, + subject: fields.subject, + inReplyTo: undefined, + references: undefined, + mimeVersion: "1.0", + interchangeType: "conversation.message", + interchangeCorrelationId: undefined, + interchangeTenantId: undefined, + interchangeAgentId: undefined, + interchangeSessionId: undefined, + interchangeOfferingId: undefined, + interchangeSchemaVersion: undefined, + traceparent: undefined, + tracestate: undefined, + }; +} + +async function makeSignedMessage( + crypto: CryptoProvider, + fields: { + from: string; + to: string[]; + subject: string; + messageId: string; + text: string; + date?: Date; + }, +): Promise<{ raw: Uint8Array; envelope: StoredEnvelope }> { + const date = fields.date ?? new Date("2026-02-01T00:00:00Z"); + const headers = headersFor({ + from: fields.from, + to: fields.to, + subject: fields.subject, + messageId: fields.messageId, + date, + }); + const signedContent = assembleSignedContent({ + kind: "conversation", + text: fields.text, + }); + const signature = await createDetachedSignatureFromProvider( + signedContent, + crypto, + ); + const raw = assembleMessage(headers, signedContent, signature); + const envelope: StoredEnvelope = { + messageId: fields.messageId, + from: fields.from, + to: fields.to, + subject: fields.subject, + date, + inReplyTo: undefined, + references: [], + interchangeType: "conversation.message", + interchangeCorrelationId: undefined, + }; + return { raw, envelope }; +} + +async function senderCrypto(): Promise { + return createEd25519Crypto(await generateKeyPair()); +} + +describe("substrate mailbox store", () => { + test("append assigns monotonic UID and bumps uidNext / highestModSeq", async () => { + const handles = await makeHandles("dep-append"); + const store = await openStore(handles); + + expect(store.uidNext).toBe(1); + expect(store.highestModSeq).toBe(0); + + const crypto = await senderCrypto(); + const a = await makeSignedMessage(crypto, { + from: "a@example.com", + to: ["run@dep.example.com"], + subject: "first", + messageId: generateMessageId("a@example.com"), + text: "first body", + }); + const b = await makeSignedMessage(crypto, { + from: "a@example.com", + to: ["run@dep.example.com"], + subject: "second", + messageId: generateMessageId("a@example.com"), + text: "second body", + }); + + const uidA = store.append(a.raw, a.envelope, []); + const uidB = store.append(b.raw, b.envelope, ["\\Seen"]); + + expect(uidA).toBe(1); + expect(uidB).toBe(2); + expect(store.uidNext).toBe(3); + expect(store.highestModSeq).toBe(2); + expect(store.messages.map((m) => m.uid)).toEqual([1, 2]); + expect(store.pendingWrites).toBe(true); + }); + + test("search filters by from, header, and flags", async () => { + const handles = await makeHandles("dep-search"); + const store = await openStore(handles); + const crypto = await senderCrypto(); + + const alice = await makeSignedMessage(crypto, { + from: "alice@example.com", + to: ["run@dep.example.com"], + subject: "hello", + messageId: generateMessageId("alice@example.com"), + text: "from alice", + }); + const bob = await makeSignedMessage(crypto, { + from: "bob@example.com", + to: ["run@dep.example.com"], + subject: "hi", + messageId: generateMessageId("bob@example.com"), + text: "from bob", + }); + const aliceUid = store.append(alice.raw, alice.envelope, ["\\Seen"]); + store.append(bob.raw, bob.envelope, []); + + const byFrom = await executeSearch("INBOX", store, { from: "alice" }); + expect(byFrom.map((r) => r.uid)).toEqual([aliceUid]); + + // The `header` predicate scans headers the envelope does not carry, so it + // reads each candidate's raw bytes on demand. Before the flush those bytes + // are the still-pending append raw; this exercises that path. + const bySubjectHeader = await executeSearch("INBOX", store, { + header: { field: "Subject", contains: "hello" }, + }); + expect(bySubjectHeader.map((r) => r.uid)).toEqual([aliceUid]); + + const seen = await executeSearch("INBOX", store, { hasFlags: ["\\Seen"] }); + expect(seen.map((r) => r.uid)).toEqual([aliceUid]); + + const unseen = await executeSearch("INBOX", store, { + missingFlags: ["\\Seen"], + }); + expect(unseen).toHaveLength(1); + expect(unseen[0]?.uid).not.toBe(aliceUid); + }); + + test("fetchFull returns the raw message and verifies its signature", async () => { + const handles = await makeHandles("dep-fetch"); + const store = await openStore(handles); + const crypto = await senderCrypto(); + const msg = await makeSignedMessage(crypto, { + from: "signer@example.com", + to: ["run@dep.example.com"], + subject: "signed", + messageId: generateMessageId("signer@example.com"), + text: "verified body", + }); + const uid = store.append(msg.raw, msg.envelope, []); + + // The resident message model carries no raw bytes; the bytes are read on + // demand and are byte-identical to the input. + expect("raw" in (store.find(uid) ?? {})).toBe(false); + expect(await store.readRaw(uid)).toEqual(msg.raw); + + const getCrypto = (from: string): CryptoProvider | undefined => + from === "signer@example.com" ? crypto : undefined; + const full = await fetchFull({ uid, mailbox: "INBOX" }, store, getCrypto); + expect(full.headers.from).toBe("signer@example.com"); + expect(full.signatureStatus).toBe("valid"); + expect(full.content).toBe("verified body"); + }); + + test("addFlags / removeFlags / remove mutate and advance modseq", async () => { + const handles = await makeHandles("dep-flags"); + const store = await openStore(handles); + const crypto = await senderCrypto(); + const one = await makeSignedMessage(crypto, { + from: "a@example.com", + to: ["run@dep.example.com"], + subject: "one", + messageId: generateMessageId("a@example.com"), + text: "one", + }); + const two = await makeSignedMessage(crypto, { + from: "a@example.com", + to: ["run@dep.example.com"], + subject: "two", + messageId: generateMessageId("a@example.com"), + text: "two", + }); + const uid1 = store.append(one.raw, one.envelope, []); + const uid2 = store.append(two.raw, two.envelope, []); + expect(store.highestModSeq).toBe(2); + + const flagged = store.addFlags(uid1, ["\\Seen", "\\Flagged"]); + expect(Array.from(flagged.flags).sort()).toEqual(["\\Flagged", "\\Seen"]); + expect(store.highestModSeq).toBe(3); + + const unflagged = store.removeFlags(uid1, ["\\Flagged"]); + expect(Array.from(unflagged.flags)).toEqual(["\\Seen"]); + expect(store.highestModSeq).toBe(4); + + store.remove(uid2); + expect(store.messages.map((m) => m.uid)).toEqual([uid1]); + expect(store.find(uid2)).toBeUndefined(); + // remove advances modseq so a QRESYNC client learns of the vanish. + expect(store.highestModSeq).toBe(5); + // uidNext never regresses even though the message was removed. + expect(store.uidNext).toBe(3); + }); + + test("sync reports changed / vanished deltas and full-resync on uidValidity change", async () => { + const handles = await makeHandles("dep-sync"); + const store = await openStore(handles); + const crypto = await senderCrypto(); + const m1 = await makeSignedMessage(crypto, { + from: "a@example.com", + to: ["run@dep.example.com"], + subject: "m1", + messageId: generateMessageId("a@example.com"), + text: "m1", + }); + const m2 = await makeSignedMessage(crypto, { + from: "a@example.com", + to: ["run@dep.example.com"], + subject: "m2", + messageId: generateMessageId("a@example.com"), + text: "m2", + }); + const uid1 = store.append(m1.raw, m1.envelope, []); + const uid2 = store.append(m2.raw, m2.envelope, []); + + // Client is caught up through the two appends (modseq 2). + const caughtUp = store.sync({ + uidValidity: store.uidValidity, + highestModSeq: 2, + }); + if (caughtUp.resync) throw new Error("unexpected resync"); + expect(caughtUp.changed).toHaveLength(0); + expect(caughtUp.vanished).toHaveLength(0); + + // A flag change on uid1 and an expunge of uid2 both advance modseq. + store.addFlags(uid1, ["\\Seen"]); + store.remove(uid2); + + const delta = store.sync({ + uidValidity: store.uidValidity, + highestModSeq: 2, + }); + if (delta.resync) throw new Error("unexpected resync"); + expect(delta.changed.map((m) => m.uid)).toEqual([uid1]); + expect(delta.vanished).toEqual([uid2]); + + // A mismatched uidValidity forces a full resync carrying the live set. + const resync = store.sync({ + uidValidity: store.uidValidity + 1, + highestModSeq: 0, + }); + if (!resync.resync) throw new Error("expected resync"); + expect(resync.messages.map((m) => m.uid)).toEqual([uid1]); + }); + + test("reopening rebuilds state from the committed index.json", async () => { + const handles = await makeHandles("dep-reopen"); + const store = await openStore(handles); + const crypto = await senderCrypto(); + const first = await makeSignedMessage(crypto, { + from: "a@example.com", + to: ["run@dep.example.com"], + subject: "keep", + messageId: generateMessageId("a@example.com"), + text: "keep me", + }); + const second = await makeSignedMessage(crypto, { + from: "b@example.com", + to: ["run@dep.example.com"], + subject: "drop", + messageId: generateMessageId("b@example.com"), + text: "drop me", + }); + const uid1 = store.append(first.raw, first.envelope, ["\\Seen"]); + const uid2 = store.append(second.raw, second.envelope, []); + store.remove(uid2); + await store.flush(); + expect(store.pendingWrites).toBe(false); + + // A second store over the same repo/ref reconstructs the mirror. + const reopened = await openStore(handles); + expect(reopened.uidValidity).toBe(store.uidValidity); + expect(reopened.uidNext).toBe(store.uidNext); + expect(reopened.highestModSeq).toBe(store.highestModSeq); + expect(reopened.messages.map((m) => m.uid)).toEqual([uid1]); + + const kept = reopened.find(uid1); + expect(kept).toBeDefined(); + expect(Array.from(kept?.flags ?? [])).toEqual(["\\Seen"]); + // The reopened mirror holds metadata only; the raw is read from the + // committed `.eml` blob on demand. + expect(await reopened.readRaw(uid1)).toEqual(first.raw); + expect(kept?.envelope.from).toBe("a@example.com"); + expect(kept?.envelope.subject).toBe("keep"); + // The expunged message's blob did not survive the flush. + expect(reopened.find(uid2)).toBeUndefined(); + + // fetchFull still verifies the signature over the reloaded raw bytes. + const getCrypto = (from: string): CryptoProvider | undefined => + from === "a@example.com" ? crypto : undefined; + const full = await fetchFull( + { uid: uid1, mailbox: "INBOX" }, + reopened, + getCrypto, + ); + expect(full.signatureStatus).toBe("valid"); + expect(full.content).toBe("keep me"); + + // The reopened store answers QRESYNC vanished from the persisted tombstone. + const delta = reopened.sync({ + uidValidity: reopened.uidValidity, + highestModSeq: 1, + }); + if (delta.resync) throw new Error("unexpected resync"); + expect(delta.vanished).toEqual([uid2]); + }); + + test("does not hold raw resident after flush and reads it from disk on demand", async () => { + const handles = await makeHandles("dep-no-resident-raw"); + const store = await openStore(handles); + const crypto = await senderCrypto(); + const msg = await makeSignedMessage(crypto, { + from: "a@example.com", + to: ["run@dep.example.com"], + subject: "resident", + messageId: generateMessageId("a@example.com"), + text: "resident body", + }); + const uid = store.append(msg.raw, msg.envelope, []); + + // The resident message model never carries the raw bytes. + expect("raw" in (store.find(uid) ?? {})).toBe(false); + // Before flush the append raw is held pending and is readable. + expect(await store.readRaw(uid)).toEqual(msg.raw); + + await store.flush(); + + // After a successful flush the bytes are dropped from memory. The pinned + // committed-read snapshot predates the commit, so the writer store can no + // longer resolve them -- direct proof the raw was released, not retained. + await expect(store.readRaw(uid)).rejects.toThrow(/not resolvable/); + + // A fresh reader opens a new snapshot that sees the committed blob and + // reads it from disk on demand, byte-identical to the original. + const reader = await openStore(handles); + expect("raw" in (reader.find(uid) ?? {})).toBe(false); + expect(await reader.readRaw(uid)).toEqual(msg.raw); + + // readRaw throws for an absent uid. + await expect(reader.readRaw(9999)).rejects.toThrow(/not found/); + }); + + test("a fresh store over an empty repo starts at uid 1 with no messages", async () => { + const handles = await makeHandles("dep-empty"); + const store = await openStore(handles); + expect(store.messages).toHaveLength(0); + expect(store.uidNext).toBe(1); + expect(store.highestModSeq).toBe(0); + expect(store.pendingWrites).toBe(false); + // Flushing a clean store issues no commit and stays clean. + await store.flush(); + expect(store.pendingWrites).toBe(false); + }); + + test("flush writes a delta and does not re-put unchanged prior blobs", async () => { + const handles = await makeHandles("dep-delta"); + const recorded: DeltaRecord[] = []; + const substrate = createRecordingSubstrate( + makeStore(handles.dataDir), + recorded, + ); + const open = () => + createSubstrateMailboxStore({ + substrate, + repoId: handles.repoId, + principal: handles.principal, + ref: REF, + }); + const inbox = (name: string) => `mailbox/INBOX/${name}`; + + const store = await open(); + const crypto = await senderCrypto(); + const first = await makeSignedMessage(crypto, { + from: "a@example.com", + to: ["run@dep.example.com"], + subject: "first", + messageId: generateMessageId("a@example.com"), + text: "first body", + }); + const uid1 = store.append(first.raw, first.envelope, []); + await store.flush(); + + // First flush puts index.json and the single new blob, deletes nothing. + expect(recorded).toHaveLength(1); + expect(recorded[0]?.puts.sort()).toEqual( + [inbox("index.json"), inbox("1.eml")].sort(), + ); + expect(recorded[0]?.deletes).toEqual([]); + + // A second append puts ONLY the new blob; uid1's blob carries forward by + // object id and is never re-hashed -- the O(N^2) anti-pattern this fix + // removes. + const second = await makeSignedMessage(crypto, { + from: "b@example.com", + to: ["run@dep.example.com"], + subject: "second", + messageId: generateMessageId("b@example.com"), + text: "second body", + }); + const uid2 = store.append(second.raw, second.envelope, []); + await store.flush(); + expect(recorded).toHaveLength(2); + expect(recorded[1]?.puts.sort()).toEqual( + [inbox("index.json"), inbox("2.eml")].sort(), + ); + expect(recorded[1]?.deletes).toEqual([]); + + // A flag change touches only index.json; no `.eml` is put or deleted. + store.addFlags(uid1, ["\\Seen"]); + await store.flush(); + expect(recorded).toHaveLength(3); + expect(recorded[2]?.puts).toEqual([inbox("index.json")]); + expect(recorded[2]?.deletes).toEqual([]); + + // Removing uid2 deletes its blob and puts index.json; uid1's blob is + // neither re-put nor deleted. + store.remove(uid2); + await store.flush(); + expect(recorded).toHaveLength(4); + expect(recorded[3]?.puts).toEqual([inbox("index.json")]); + expect(recorded[3]?.deletes).toEqual([inbox("2.eml")]); + + // The committed tree still reconstructs identically: uid1 survives with its + // flag, uid2 is gone. + const reopened = await open(); + expect(reopened.messages.map((m) => m.uid)).toEqual([uid1]); + expect(Array.from(reopened.find(uid1)?.flags ?? [])).toEqual(["\\Seen"]); + expect(reopened.find(uid2)).toBeUndefined(); + }); +}); diff --git a/vendor/intx-workflow-host/adapters/substrate-mailbox-store.ts b/vendor/intx-workflow-host/adapters/substrate-mailbox-store.ts new file mode 100644 index 000000000..8e75a7df1 --- /dev/null +++ b/vendor/intx-workflow-host/adapters/substrate-mailbox-store.ts @@ -0,0 +1,563 @@ +// Workflow-run-substrate backing for the `@intx/mailbox` `MailboxStore`. +// +// The `MailboxStore` mutation surface is SYNCHRONOUS, but the workflow-run +// substrate is an async git store. This backing follows the shape of an IMAP +// client with a local cache: the async `createSubstrateMailboxStore` factory +// loads the committed `mailbox/INBOX/` METADATA (`index.json`) into an +// in-memory mirror on open, exposes the synchronous mutation surface over that +// mirror, and persists the current state back to the substrate through +// `flush`. Callers mutate synchronously (append / addFlags / removeFlags / +// remove) and `await flush()` at a boundary. +// +// The per-message raw RFC 2822 bytes are NOT loaded on open and are NOT held +// resident: `readRaw(uid)` reads a message's `.eml` blob on demand from +// the committed-read snapshot the open pinned. The only raw this backing keeps +// in memory is that of a message appended-but-not-yet-flushed; a successful +// `flush` drops it. So the resident footprint of a long-lived warm mailbox is +// bounded to metadata regardless of how much mail it has accumulated. +// +// On-disk layout, a top-level subtree of the workflow-run repo (one mailbox per +// deployment repo): +// +// mailbox/INBOX/index.json committed metadata: uidValidity, the +// uid/modseq counters, one entry per live +// message (uid, modseq, flags, pre-parsed +// envelope), and the expunged-uid tombstones +// QRESYNC answers `vanished` from. +// mailbox/INBOX/.eml the verbatim raw RFC 2822 bytes of each live +// message, so `fetchFull` verifies signatures +// byte-exactly. Write-once per uid. +// +// Reads resolve from the committed substrate (`openCommittedReads`), never the +// lagging working tree, matching the sibling `mail-part-store` reader. Writes +// go through `writeTreeDelta`: `index.json` changes on every mutation and is +// always put; each `.eml` is immutable, so a flush puts only the blobs +// appended since the last successful flush and deletes only those whose +// message was removed since then, and the substrate carries every untouched +// `.eml` forward by object id. This keeps a flush O(delta) rather than +// O(mailbox), so a long-lived warm conversational mailbox does not re-hash its +// whole history on each append or flag change. The committed subtree the delta +// leaves behind is byte-identical in shape to a full rewrite of the same live +// set. The kind handler's push-time validation of this subtree is owned +// separately by the hub replication layer; this module owns the on-disk shape +// it validates. + +import { type } from "arktype"; +import type { + Principal, + RepoId, + RepoStore as SubstrateRepoStore, +} from "@intx/hub-sessions/substrate"; +import type { + MailboxStore, + StoredEnvelope, + StoredMessage, +} from "@intx/mailbox"; + +/** Top-level subtree of the workflow-run repo that holds the mailbox. */ +export const MAILBOX_PREFIX = "mailbox"; +/** The single mailbox this backing persists, an IMAP INBOX. */ +export const MAILBOX_INBOX_DIR = "INBOX"; +/** Committed metadata blob name directly under `mailbox/INBOX/`. */ +export const MAILBOX_INDEX_FILE = "index.json"; +/** Suffix of a per-message raw-bytes blob (`.eml`). */ +export const MAILBOX_EML_SUFFIX = ".eml"; + +/** + * The `mailbox/INBOX/` prefix, ending in `/` as + * `writeTreePreservingPrefix` requires. Every blob this backing writes is a + * direct child of it. + */ +export const MAILBOX_INBOX_PREFIX = `${MAILBOX_PREFIX}/${MAILBOX_INBOX_DIR}/`; + +/** Relative directory path of the INBOX, for `CommittedReads.listDir`. */ +const MAILBOX_INBOX_DIR_PATH = `${MAILBOX_PREFIX}/${MAILBOX_INBOX_DIR}`; + +/** Current on-disk schema version of `index.json`. */ +const INDEX_VERSION = 1; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +/** + * On-disk envelope shape. Mirrors `StoredEnvelope` but serializes `date` as an + * ISO string and the three nullable header fields as `string | null` (JSON has + * no `undefined`); the loader maps `null` back to `undefined`. + */ +const StoredEnvelopeJson = type({ + messageId: "string", + from: "string", + to: "string[]", + subject: "string", + date: "string", + inReplyTo: "string | null", + references: "string[]", + interchangeType: "string | null", + interchangeCorrelationId: "string | null", +}); + +/** + * On-disk `index.json` shape. Validated on every open: the committed tree is + * durable but external to this process, so it is parsed at the boundary rather + * than trusted. `expunged` records the uid and the modseq at which each + * message vanished so a QRESYNC `sync` can answer `vanished` since a client's + * known modseq. + */ +const MailboxIndexJson = type({ + version: `${INDEX_VERSION}`, + uidValidity: "number >= 0", + uidNext: "number >= 1", + highestModSeq: "number >= 0", + messages: type({ + uid: "number >= 1", + modseq: "number >= 1", + flags: "string[]", + envelope: StoredEnvelopeJson, + }).array(), + expunged: type({ + uid: "number >= 1", + modseq: "number >= 1", + }).array(), +}); + +type MailboxIndexJson = typeof MailboxIndexJson.infer; + +/** A message the client no longer holds, with the modseq at which it vanished. */ +type ExpungedRecord = { uid: number; modseq: number }; + +/** + * The client's last-known synchronization state, per QRESYNC (RFC 7162). A + * mismatched `uidValidity` forces a full resync; otherwise `highestModSeq` + * bounds the changed / vanished deltas. + */ +export type MailboxSyncKnownState = { + uidValidity: number; + highestModSeq: number; +}; + +/** + * The result of a QRESYNC `sync`. `resync: true` signals the client's + * `uidValidity` no longer matches the mailbox, so it must discard its cache + * and take the full `messages` snapshot. `resync: false` carries the deltas + * since the client's `highestModSeq`: `changed` is every live message whose + * modseq advanced past it (new arrivals and flag changes alike), and + * `vanished` is every uid expunged past it. + */ +export type MailboxSyncResult = + | { + resync: true; + uidValidity: number; + uidNext: number; + highestModSeq: number; + messages: readonly StoredMessage[]; + } + | { + resync: false; + uidValidity: number; + uidNext: number; + highestModSeq: number; + changed: readonly StoredMessage[]; + vanished: readonly number[]; + }; + +/** + * A `MailboxStore` whose state is durable in the workflow-run substrate. The + * synchronous `MailboxStore` surface reads and mutates an in-memory mirror; + * `flush` persists that mirror to `mailbox/INBOX/`; `sync` answers a QRESYNC + * delta against a client's known state. + */ +export interface SubstrateMailboxStore extends MailboxStore { + /** True when a mutation has occurred that `flush` has not yet persisted. */ + readonly pendingWrites: boolean; + /** + * Persist the current mirror to the substrate through a delta write: + * `index.json` (always), the `.eml` blobs appended since the last + * successful flush, and deletions for the blobs whose message was removed + * since then. Every untouched `.eml` is carried forward by object id. A + * no-op when no mutation is pending. + */ + flush(): Promise; + /** Compute the QRESYNC delta between the mailbox and a client's known state. */ + sync(known: MailboxSyncKnownState): MailboxSyncResult; +} + +export type SubstrateMailboxStoreOpts = { + substrate: SubstrateRepoStore; + repoId: RepoId; + principal: Principal; + ref: string; +}; + +function serializeEnvelope(envelope: StoredEnvelope) { + return { + messageId: envelope.messageId, + from: envelope.from, + to: envelope.to, + subject: envelope.subject, + date: envelope.date.toISOString(), + inReplyTo: envelope.inReplyTo ?? null, + references: envelope.references, + interchangeType: envelope.interchangeType ?? null, + interchangeCorrelationId: envelope.interchangeCorrelationId ?? null, + }; +} + +function deserializeEnvelope( + raw: MailboxIndexJson["messages"][number]["envelope"], +): StoredEnvelope { + return { + messageId: raw.messageId, + from: raw.from, + to: raw.to, + subject: raw.subject, + date: new Date(raw.date), + inReplyTo: raw.inReplyTo === null ? undefined : raw.inReplyTo, + references: raw.references, + interchangeType: + raw.interchangeType === null ? undefined : raw.interchangeType, + interchangeCorrelationId: + raw.interchangeCorrelationId === null + ? undefined + : raw.interchangeCorrelationId, + }; +} + +/** The `.eml` blob name for a message. */ +function emlName(uid: number): string { + return `${String(uid)}${MAILBOX_EML_SUFFIX}`; +} + +/** + * A pinned committed-read snapshot of the repo the store opened against, the + * source `readRaw` resolves a live message's `.eml` from. `null` when the + * repo, ref, or subtree did not exist at open. + */ +type CommittedReads = Awaited< + ReturnType +>; + +type LoadedState = { + uidValidity: number; + uidNext: number; + highestModSeq: number; + messages: StoredMessage[]; + expunged: ExpungedRecord[]; + /** + * The pinned committed-read snapshot opened at load, retained so `readRaw` + * can resolve a message's `.eml` blob on demand without re-opening. + */ + reads: CommittedReads; + /** The `.eml` object id per committed uid, for `readRaw`. */ + oidByUid: Map; +}; + +/** + * Load the committed `mailbox/INBOX/` METADATA into an in-memory state, or the + * empty state (a fresh `uidValidity`) when the repo, the ref, or the subtree + * does not yet exist. Only `index.json` is read; the per-message `.eml` + * blobs stay on disk and are read lazily by `readRaw`, so an open's resident + * footprint is bounded to metadata regardless of mailbox size. The committed + * read snapshot and the uid->oid map are retained so `readRaw` resolves a + * blob against the same pinned commit the open observed. Every read resolves + * against the committed object store, so an open observes committed state even + * when the working tree lags. + */ +async function loadCommittedState( + opts: SubstrateMailboxStoreOpts, +): Promise { + const empty = (reads: CommittedReads): LoadedState => ({ + uidValidity: Date.now(), + uidNext: 1, + highestModSeq: 0, + messages: [], + expunged: [], + reads, + oidByUid: new Map(), + }); + + const reads = await opts.substrate.openCommittedReads( + opts.principal, + opts.repoId, + opts.ref, + ); + if (reads === null) return empty(reads); + + const entries = await reads.listDir(MAILBOX_INBOX_DIR_PATH); + const indexEntry = entries.find( + (e) => e.name === MAILBOX_INDEX_FILE && e.type === "blob", + ); + if (indexEntry === undefined) return empty(reads); + + const indexBytes = await reads.readBlobByOid(indexEntry.oid); + let parsedJson: unknown; + try { + parsedJson = JSON.parse(decoder.decode(indexBytes)); + } catch (cause) { + throw new Error( + `substrate mailbox store: ${MAILBOX_INBOX_PREFIX}${MAILBOX_INDEX_FILE} is not valid JSON`, + { cause }, + ); + } + const index = MailboxIndexJson(parsedJson); + if (index instanceof type.errors) { + throw new Error( + `substrate mailbox store: invalid ${MAILBOX_INBOX_PREFIX}${MAILBOX_INDEX_FILE}: ${index.summary}`, + ); + } + + const emlByName = new Map( + entries + .filter((e) => e.type === "blob" && e.name.endsWith(MAILBOX_EML_SUFFIX)) + .map((e) => [e.name, e.oid]), + ); + + const messages: StoredMessage[] = []; + const oidByUid = new Map(); + for (const entry of index.messages) { + const oid = emlByName.get(emlName(entry.uid)); + if (oid === undefined) { + throw new Error( + `substrate mailbox store: index references message uid ${String( + entry.uid, + )} but ${MAILBOX_INBOX_PREFIX}${emlName(entry.uid)} is absent`, + ); + } + // The blob's presence is asserted by its object id; its bytes are not read + // here -- `readRaw` reads them on demand. + oidByUid.set(entry.uid, oid); + messages.push({ + uid: entry.uid, + modseq: entry.modseq, + flags: new Set(entry.flags), + envelope: deserializeEnvelope(entry.envelope), + }); + } + + return { + uidValidity: index.uidValidity, + uidNext: index.uidNext, + highestModSeq: index.highestModSeq, + messages, + expunged: index.expunged.map((e) => ({ uid: e.uid, modseq: e.modseq })), + reads, + oidByUid, + }; +} + +/** + * Create a workflow-run-substrate-backed `MailboxStore`. Loads the committed + * `mailbox/INBOX/` subtree into an in-memory mirror, then serves the + * synchronous `MailboxStore` surface over that mirror. Mutations stay in + * memory until `flush` persists them. + */ +export async function createSubstrateMailboxStore( + opts: SubstrateMailboxStoreOpts, +): Promise { + const state = await loadCommittedState(opts); + + const messages = state.messages; + const expunged = state.expunged; + const uidValidity = state.uidValidity; + const reads = state.reads; + const oidByUid = state.oidByUid; + let uidCounter = state.uidNext; + // The next modseq to assign. `highestModSeq` is the largest assigned, so the + // next is one past it; a fresh mailbox (highestModSeq 0) starts at 1. + let modseqCounter = state.highestModSeq + 1; + let dirty = false; + + // Delta tracking for `flush`. `index.json` changes on every mutation, so it + // is put unconditionally; each `.eml` is immutable and written once, so + // a flush need only put the blobs appended since the last successful flush + // and delete the blobs whose message was removed since then. The removed set + // clears on a successful flush; a flush that throws leaves it intact so the + // next flush re-attempts the same delta. + // + // `pendingRawByUid` holds the raw bytes of appended-but-not-yet-flushed + // messages -- the only raw this backing keeps resident. It doubles as the + // "appended since flush" set: `flush` puts each entry's blob, then drops it + // so a flushed message's bytes leave memory and are read from disk on demand. + const pendingRawByUid = new Map(); + const removedSinceFlush = new Set(); + + function find(uid: number): StoredMessage | undefined { + return messages.find((m) => m.uid === uid); + } + + function require(uid: number): StoredMessage { + const msg = find(uid); + if (msg === undefined) { + throw new Error(`Message UID ${String(uid)} not found`); + } + return msg; + } + + async function flush(): Promise { + if (!dirty) return; + + const index = { + version: INDEX_VERSION, + uidValidity, + uidNext: uidCounter, + highestModSeq: modseqCounter - 1, + messages: messages.map((m) => ({ + uid: m.uid, + modseq: m.modseq, + flags: Array.from(m.flags), + envelope: serializeEnvelope(m.envelope), + })), + expunged: expunged.map((e) => ({ uid: e.uid, modseq: e.modseq })), + }; + + // `index.json` is put on every flush. Each `.eml` is immutable, so + // only the blobs appended since the last successful flush are put and only + // those whose message was removed are deleted; every other `.eml` is + // carried forward by object id, so the flush never re-hashes the mailbox's + // whole history. + const puts: Record = { + [`${MAILBOX_INBOX_PREFIX}${MAILBOX_INDEX_FILE}`]: encoder.encode( + JSON.stringify(index), + ), + }; + const flushedUids: number[] = []; + for (const [uid, raw] of pendingRawByUid) { + puts[`${MAILBOX_INBOX_PREFIX}${emlName(uid)}`] = raw; + flushedUids.push(uid); + } + const deletes = Array.from( + removedSinceFlush, + (uid) => `${MAILBOX_INBOX_PREFIX}${emlName(uid)}`, + ); + + await opts.substrate.writeTreeDelta(opts.principal, opts.repoId, opts.ref, { + computeDelta: async () => ({ puts, deletes }), + changedPathPrefixes: new Set([MAILBOX_INBOX_PREFIX]), + message: `persist mailbox INBOX (${String(messages.length)} message(s))`, + }); + // The appended blobs are now committed, so their raw leaves memory: a later + // `readRaw` reads them from disk. Their object ids are not recorded here + // (the pinned committed-read snapshot predates this commit), so `readRaw` + // resolves a post-open append only while its raw is still pending; the + // long-lived writer never reads its own appends back, and every reader + // opens a fresh snapshot that sees the committed blob. + for (const uid of flushedUids) { + pendingRawByUid.delete(uid); + } + removedSinceFlush.clear(); + dirty = false; + } + + function sync(known: MailboxSyncKnownState): MailboxSyncResult { + const highestModSeq = modseqCounter - 1; + if (known.uidValidity !== uidValidity) { + return { + resync: true, + uidValidity, + uidNext: uidCounter, + highestModSeq, + messages: messages.slice(), + }; + } + const changed = messages + .filter((m) => m.modseq > known.highestModSeq) + .sort((a, b) => a.uid - b.uid); + const vanished = expunged + .filter((e) => e.modseq > known.highestModSeq) + .map((e) => e.uid) + .sort((a, b) => a - b); + return { + resync: false, + uidValidity, + uidNext: uidCounter, + highestModSeq, + changed, + vanished, + }; + } + + return { + uidValidity, + get uidNext() { + return uidCounter; + }, + get highestModSeq() { + return modseqCounter - 1; + }, + get messages() { + return messages; + }, + get pendingWrites() { + return dirty; + }, + append(raw, envelope, flags) { + const uid = uidCounter++; + const modseq = modseqCounter++; + messages.push({ uid, modseq, flags: new Set(flags), envelope }); + pendingRawByUid.set(uid, raw); + dirty = true; + return uid; + }, + async readRaw(uid) { + if (find(uid) === undefined) { + throw new Error(`Message UID ${String(uid)} not found`); + } + // An appended-but-not-yet-flushed message keeps its raw in memory; a + // flushed or previously-committed message reads its blob from the pinned + // committed-read snapshot on demand. + const pending = pendingRawByUid.get(uid); + if (pending !== undefined) return pending; + const oid = oidByUid.get(uid); + if (oid === undefined || reads === null) { + throw new Error( + `substrate mailbox store: no committed blob for message uid ${String( + uid, + )}; its raw bytes are not resolvable from this snapshot`, + ); + } + return reads.readBlobByOid(oid); + }, + find, + addFlags(uid, flags) { + const msg = require(uid); + for (const flag of flags) { + msg.flags.add(flag); + } + msg.modseq = modseqCounter++; + dirty = true; + return msg; + }, + removeFlags(uid, flags) { + const msg = require(uid); + for (const flag of flags) { + msg.flags.delete(flag); + } + msg.modseq = modseqCounter++; + dirty = true; + return msg; + }, + remove(uid) { + const idx = messages.findIndex((m) => m.uid === uid); + if (idx === -1) { + throw new Error(`Message UID ${String(uid)} not found`); + } + messages.splice(idx, 1); + // Record the expunge with a fresh modseq so a QRESYNC `sync` can report + // this uid as `vanished` to a client whose known modseq predates it. The + // in-memory reference backing does not advance modseq on remove; this + // backing does, because it must answer QRESYNC across reopens. + expunged.push({ uid, modseq: modseqCounter++ }); + // A message appended and removed within the same flush window was never + // committed, so its `.eml` must be neither put nor deleted: drop its + // pending raw. Otherwise the blob is already committed and the next flush + // deletes it. + if (pendingRawByUid.has(uid)) { + pendingRawByUid.delete(uid); + } else { + removedSinceFlush.add(uid); + } + dirty = true; + }, + flush, + sync, + }; +} From 28a81723951149c5460b05856e08a8ab24988d02 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 7 Sep 2026 14:00:31 -0700 Subject: [PATCH 2/2] Fix stale bunfig exclusion note and the mailbox kill condition The browser-bundle exclusion comment still claimed @intx/mime was dist-only; mime is vendored source now. A local re-run of the excluded bundle test fails on @intx/crypto (published, dist-only, unresolvable from inside the vendor workspace), so the exclusion stays with a corrected explanation. The mailbox kill condition keyed to 0.4.0 could never trip because the package has no published versions at all; it now keys to any version publishing. --- bunfig.toml | 7 ++++--- docs/VENDORING.md | 7 +++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/bunfig.toml b/bunfig.toml index 0899fc563..46d05e992 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -6,9 +6,10 @@ pathIgnorePatterns = [ "tmp/**", # Bundles vendor/intx-storage-isogit's browser entry point with Bun.build, # under the "intx-src" export condition that resolves @intx/log and - # @intx/mime to ./src/*.ts. @intx/log is vendored source, but @intx/mime - # stays on published npm (dist-only, no src/), so the condition matches an - # export key whose target file doesn't exist. Not a vendored-code defect — + # @intx/mime to ./src/*.ts. Both are vendored source now, but the bundle + # still fails: the vendored mime sources import @intx/crypto, which stays + # on published npm (dist-only), and Bun.build cannot resolve that bare + # specifier from inside the vendor workspace. Not a vendored-code defect — # see docs/VENDORING.md. "vendor/intx-storage-isogit/src/browser-bundle.test.ts", ] diff --git a/docs/VENDORING.md b/docs/VENDORING.md index d0b925563..d738ffc29 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -52,7 +52,7 @@ package; the date is the deadline even if it is not. | `vendor/intx-authz/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/authz@>=0.4.0` publishes | | `vendor/intx-log/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/log@>=0.4.0` publishes | | `vendor/intx-tools-posix/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/tools-posix@>=0.4.0` publishes | -| `vendor/intx-mailbox/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Never published to npm (verified 2026-09-07: registry 404 for all versions) | step-1 | 2027-03-07 or when `@intx/mailbox@>=0.4.0` publishes | +| `vendor/intx-mailbox/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Never published to npm (verified 2026-09-07: registry 404 for all versions) | step-1 | 2027-03-07 or when any `@intx/mailbox` version publishes to npm | | `vendor/intx-harness/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | `driveConnectorReplies`/`AgentEventStream` (`src/reply-drain.ts`) is past npm `0.3.0` (verified 2026-09-07: absent from the published tarball) | step-1 | 2027-03-07 or when a published `@intx/harness` exports `driveConnectorReplies` | | `vendor/intx-mime/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | `buildMessageHeaders` is past npm `0.3.0` (verified 2026-09-07: absent from the published tarball); `@intx/mailbox` re-exports it | step-1 | 2027-03-07 or when a published `@intx/mime` exports `buildMessageHeaders` | | `vendor/intx-workflow-host/adapters/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | App-internal: `substrate-mailbox-store.ts` has never been published in any `@intx/workflow-host` release (verified 2026-09-07: absent from the `0.3.0` tarball) | step-1 | 2027-03-07 or when a published `@intx/workflow-host` exports `createSubstrateMailboxStore` | @@ -217,7 +217,10 @@ One new upstream test, `browser-bundle.test.ts`, is excluded via `@intx/log` and `@intx/mime` to `./src/*.ts`. Both are vendored source as of the 2026-09-07 syncs (`@intx/mime` in the step-1 pass, which vendored it for `buildMessageHeaders`), so the condition has a `src/` target again; -the test stays excluded pending a re-run of the bundle in CI conditions. +the exclusion remains because a local re-run of the bundle fails for a +different reason — the vendored mime sources import `@intx/crypto`, which +stays on published npm, and `Bun.build` cannot resolve that bare specifier +from inside the vendor workspace. `@intx/inference` carries local patches — real fixes not yet present upstream, not workarounds for something upstream has since fixed. Every