From 43066aace2a1a993edf5b1ba9155c79d13648572 Mon Sep 17 00:00:00 2001 From: leemour Date: Sat, 10 Oct 2026 21:39:06 +0200 Subject: [PATCH 1/2] fix: prepare compatible messaging 0.217.1 without schema changes --- CHANGELOG.md | 9 +++ docs/dev/STANDARD.md | 2 +- docs/replies.md | 6 +- docs/search/ai-providers.md | 2 +- package.json | 4 +- parity.json | 2 +- pnpm-lock.yaml | 10 ++-- socket.yml | 3 + src/attachments/directory.test.ts | 8 ++- src/attachments/directory.ts | 4 +- src/attachments/extract.test.ts | 70 ++++++++++------------ src/attachments/extract.ts | 10 +--- src/attachments/pdf-preview.test.ts | 6 +- src/attachments/pdf-preview.ts | 4 +- src/cli/bot/messages.ts | 2 +- src/cli/messenger/messages-send-command.ts | 2 +- src/cli/messenger/replies-command.test.ts | 2 +- src/embeddings/remote.test.ts | 4 +- src/embeddings/remote.ts | 1 - src/mcp/mcp.test.ts | 12 ++-- src/mcp/text.ts | 3 +- src/mcp/tool.ts | 3 +- src/mcp/tools/attachments.ts | 2 +- src/mcp/tools/contacts.ts | 6 +- src/mcp/tools/messages-actions.ts | 2 +- src/mcp/tools/messages-send.ts | 2 +- src/models/types.ts | 4 +- src/render/markdown.test.ts | 28 ++++----- src/render/markdown.ts | 4 +- src/replies/rendering.ts | 2 +- src/replies/template.test.ts | 38 +----------- src/replies/template.ts | 25 +------- src/sends/upload.test.ts | 8 ++- src/sends/upload.ts | 23 ++++--- src/speech/ogg-limit.test.ts | 6 +- src/speech/ogg-limit.ts | 7 +-- 36 files changed, 136 insertions(+), 190 deletions(-) create mode 100644 socket.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ad9052f..197be5f50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ Notable changes to `@wirecat/cli-messaging` (`@leemour/cli-messaging` up to 0.21 version, newest first. Versions follow [semver](https://semver.org/); before `1.0.0` a minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the shape of this file. +## 0.217.1 — 10.10.2026 + +### Fixed + +- Long PDF documents and voice recordings no longer hit separate page/time/duration cutoffs; caller cancellation and format/allocation checks remain. +- Ordinary hidden developer folders are allowed while known credentials and application state stay protected. Markdown bodies, original Unicode writes, registry defaults, embedding redirects and interpolated reply previews retain their normal behavior. +- Windows maintenance setup uses compatible core 0.19.1. + + ## 0.217.0 — 10.10.2026 ### Fixed diff --git a/docs/dev/STANDARD.md b/docs/dev/STANDARD.md index 28d726003..36c1dc680 100644 --- a/docs/dev/STANDARD.md +++ b/docs/dev/STANDARD.md @@ -309,7 +309,7 @@ its meaning, and this table is regenerated from it. **Bold** marks a clash still | `--all` | | every row, no paging. **chats requests accept and decline take every pending request instead — Telegram counts them against its hourly limit first; MAX has no way to answer them all at once and refuses it, naming chats requests list — so it differs on purpose (Help text rule 4)** | | `attachments list`, `chats list`, `chats members list` (planned), `chats requests accept`, `chats requests decline`, `contacts list`, `inbox` (planned), `messages download`, `review` (planned), `store export`, `store fetch` | | `--all-bots` | | also read every other bot's copy on this machine that readOtherBots allows | | `bot contacts show`, `bot messages between`, `bot search messages` | | `--all-can-pin` | `` | every member may pin messages | | `chats update` | -| `--allow-any-file` | | send a --file even from a hidden folder, ~/.ssh or the tool's own folders | | `bot messages send`, `messages send` | +| `--allow-any-file` | | send a --file even from credential folders or the tool's own folders | | `bot messages send`, `messages send` | | `--allow-chats` | `` | replace allowed chat ids, comma-separated; empty clears | | `replies audience` | | `--allow-dangerous` | | go ahead without the question an ask level puts before a deletion | | `bot chats moderate`, `bot mcp`, `bot mcp config`, `bot messages delete`, `chats clear` (planned), `chats delete` (planned), `chats moderate` (planned), `mcp` (planned), `mcp config` (planned), `mcp doctor` (planned), `mcp setup` (planned), `messages delete`, `store clear` | | `--allow-delete` | | offer the tool that deletes messages for you only; it cannot be undone. **retired access flag: accepted with a warning, grants no permissions in either CLI** | | `bot mcp`, `bot mcp config`, `mcp`, `mcp config`, `mcp doctor` (planned), `mcp setup` (planned) | diff --git a/docs/replies.md b/docs/replies.md index 2b6674d14..bd5624e8d 100644 --- a/docs/replies.md +++ b/docs/replies.md @@ -67,10 +67,8 @@ Thanks, {{ sender.firstName | default: "there" }}. {% ai %}Write a brief acknowledgement; I will answer tomorrow.{% else %}I will answer tomorrow.{% endai %} ``` -The block's owner-written body is the instruction. Interpolated names, titles and other values -go separately as untrusted template data, referenced at their insertion points. Incoming message -text is separate untrusted data too. JSON previews show those values in each block’s -`templateValues` beside the instruction’s insertion references. Model output replaces only the block and is never parsed again as Liquid. Text outside the +The block's rendered body is the instruction, and incoming message text goes separately as untrusted +data. Model output replaces only the block and is never parsed again as Liquid. Text outside the block remains the owner's text with its normal substitutions. When a provider is missing, a call fails or consent is absent, the else branch is used. Without one the reply is skipped with a reason. Nested AI blocks are refused. Per rendering: at most four model calls, 512 output tokens per call, diff --git a/docs/search/ai-providers.md b/docs/search/ai-providers.md index e9223765d..19cad433c 100644 --- a/docs/search/ai-providers.md +++ b/docs/search/ai-providers.md @@ -87,4 +87,4 @@ The runner reads the same shipped `link-conversations` skill as your agent, adds Anthropic uses the official TypeScript SDK and [Messages streaming](https://platform.claude.com/docs/en/cli-sdks-libraries/sdks/typescript); the runner reads the final message and checks its stop reason. The OpenAI-compatible adapter checks the first choice's finish reason and reported token usage. Both adapters are tested with local fake servers; there are no real provider calls in the test suite. Embedding endpoints must accept requests at the configured URL directly. Set `embeddingBaseUrl` to -the API base serving `/embeddings` directly; credential-bearing embedding requests refuse redirects. +the API base serving `/embeddings`; configured gateways use normal HTTP redirect handling. diff --git a/package.json b/package.json index 4ba996415..da565d98d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wirecat/cli-messaging", - "version": "0.217.0", + "version": "0.217.1", "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store", "license": "Apache-2.0", "author": "Viacheslav Ptsarev", @@ -132,7 +132,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.14", - "@wirecat/cli-core": "0.19.0", + "@wirecat/cli-core": "0.19.1", "@modelcontextprotocol/client": "2.2.0", "@napi-rs/canvas": "1.0.10", "@types/node": "^22.20.4", diff --git a/parity.json b/parity.json index f332e41b3..378c6b941 100644 --- a/parity.json +++ b/parity.json @@ -42,7 +42,7 @@ "meaning": "every member may pin messages" }, "--allow-any-file": { - "meaning": "send a --file even from a hidden folder, ~/.ssh or the tool's own folders" + "meaning": "send a --file even from credential folders or the tool's own folders" }, "--allow-chats": { "meaning": "replace allowed chat ids, comma-separated; empty clears", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f4dca711..d56fe8f5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,8 +104,8 @@ importers: specifier: ^5.0.2 version: 5.0.2(vitest@5.0.2) '@wirecat/cli-core': - specifier: 0.19.0 - version: 0.19.0(commander@15.0.0)(typescript@7.0.2) + specifier: 0.19.1 + version: 0.19.1(commander@15.0.0)(typescript@7.0.2) cli-messaging-0.13: specifier: npm:@leemour/cli-messaging@0.13.0 version: '@leemour/cli-messaging@0.13.0(commander@15.0.0)(typescript@7.0.2)' @@ -1025,8 +1025,8 @@ packages: '@wasm-audio-decoders/opus-ml@0.0.3': resolution: {integrity: sha512-SEeTmk0eb3q5etJA/NHCRWW6BivTx9tqvNTmG0kg2ynuATEQRUZnwQE6Ibvl1BBvqIGHqI57+qyJMWmJBwb5XQ==} - '@wirecat/cli-core@0.19.0': - resolution: {integrity: sha512-pGtphpeXqS87eRu20CJoB28MRwKdqKuHQe7ORuyiFo+BgA/IZ1KMPfP4VYXye3/kSAWksDLojOLvfyeslhk4wA==} + '@wirecat/cli-core@0.19.1': + resolution: {integrity: sha512-Q2OXHHbVAIxqbeCzGaDeYNqnibaox6TaL4X4Dr1VWYuVYVUYDFuJVQObjtZ80CJ8FnmX7VfYVwrJhWhBt6+aRA==} engines: {node: '>=22'} hasBin: true peerDependencies: @@ -2737,7 +2737,7 @@ snapshots: dependencies: '@wasm-audio-decoders/common': 9.0.7 - '@wirecat/cli-core@0.19.0(commander@15.0.0)(typescript@7.0.2)': + '@wirecat/cli-core@0.19.1(commander@15.0.0)(typescript@7.0.2)': dependencies: cli-table3: 0.6.5 cross-spawn: 7.0.6 diff --git a/socket.yml b/socket.yml new file mode 100644 index 000000000..b5c0fa2e2 --- /dev/null +++ b/socket.yml @@ -0,0 +1,3 @@ +version: 2 +githubApp: + enabled: false diff --git a/src/attachments/directory.test.ts b/src/attachments/directory.test.ts index 4996383e4..802e3bf89 100644 --- a/src/attachments/directory.test.ts +++ b/src/attachments/directory.test.ts @@ -70,7 +70,7 @@ describe("directory attachment matching", () => { } }) - it("refuses hidden and application folders through both typed paths and directory aliases", async () => { + it("refuses credential and application folders through both typed paths and directory aliases", async () => { const { store, folder } = await fixture() const env = { ...process.env, @@ -92,8 +92,10 @@ describe("directory attachment matching", () => { for (const path of [directory, alias]) await expect(directoryPaths(store, owner, "1", path, app, env)).rejects.toThrow("cannot read") } - writeFileSync(join(folder, ".private.txt"), "synthetic") - await expect(directoryPaths(store, owner, "1", folder, app, env)).rejects.toThrow("cannot read hidden files") + const allowed = join(folder, ".worktrees") + mkdirSync(allowed) + writeFileSync(join(allowed, ".private.txt"), "synthetic") + await expect(directoryPaths(store, owner, "1", allowed, app, env)).resolves.toBeInstanceOf(Map) } finally { await store.close() } diff --git a/src/attachments/directory.ts b/src/attachments/directory.ts index 8ac15b851..598c8dd0a 100644 --- a/src/attachments/directory.ts +++ b/src/attachments/directory.ts @@ -19,7 +19,7 @@ export const directoryPaths = async ( if (refusedPlace(directory, app, env)) throw new CliError( "validation_error", - "--from-dir cannot read hidden folders, the CLI's own folders or the message store", + "--from-dir cannot read credential folders, the CLI's own folders or the message store", ) const root = await realpath(directory).catch(() => { throw new CliError("validation_error", "--from-dir must name an existing directory") @@ -39,7 +39,7 @@ export const directoryPaths = async ( if (refusedPlace(canonical, app, env)) throw new CliError( "validation_error", - "--from-dir cannot read hidden files, the CLI's own files or the message store", + "--from-dir cannot read credential files, the CLI's own files or the message store", ) const inside = relative(root, canonical) if (inside === ".." || inside.startsWith(`..${sep}`)) diff --git a/src/attachments/extract.test.ts b/src/attachments/extract.test.ts index 5c00a24c5..edc39088d 100644 --- a/src/attachments/extract.test.ts +++ b/src/attachments/extract.test.ts @@ -39,54 +39,48 @@ describe("reading the text layer of a file", () => { expect(extractRawText).not.toHaveBeenCalled() }) - it("refuses oversized PDF page counts before reading text and closes the document", async () => { - const destroy = vi.fn(async () => {}) - const read = vi.fn() - const load: LoadEngine = async () => ({ - getDocumentProxy: async () => ({ numPages: 100_000, loadingTask: { destroy } }), - extractText: read, - }) - expect(await extractText(pdf(), hint("large.pdf"), load)).toEqual({ status: "too-large" }) - expect(read).not.toHaveBeenCalled() - expect(destroy).toHaveBeenCalledOnce() - }) - - it("bounds stuck PDF text extraction and cancels a pending document load", async () => { + it("extracts a long PDF and closes its document without a fixed page/time cutoff", async () => { vi.useFakeTimers() try { const destroy = vi.fn(async () => {}) - const read = vi.fn(() => new Promise(() => {})) const load: LoadEngine = async () => ({ - getDocumentProxy: async () => ({ numPages: 1, loadingTask: { destroy } }), - extractText: read, + getDocumentProxy: async () => ({ numPages: 100, loadingTask: { destroy } }), + extractText: async () => { + await new Promise((resolve) => setTimeout(resolve, 31_000)) + return { text: ["Synthetic long document"], totalPages: 100 } + }, }) - const pending = extractText(pdf(), hint("stuck.pdf"), load) - await vi.advanceTimersByTimeAsync(30_000) - expect(await pending).toMatchObject({ status: "unreadable" }) + const pending = extractText(pdf(), hint("long.pdf"), load) + await vi.advanceTimersByTimeAsync(31_000) + expect(await pending).toMatchObject({ status: "extracted", pages: 100 }) expect(destroy).toHaveBeenCalledOnce() - const abort = new AbortController() - let finish = (_value: unknown) => {} - const loading: LoadEngine = async () => ({ - getDocumentProxy: () => - new Promise((resolve) => { - finish = resolve - }), - extractText: read, - }) - const cancelled = extractText(pdf(), hint("cancelled.pdf"), loading, abort.signal) - const rejected = expect(cancelled).rejects.toMatchObject({ code: "cancelled" }) - await vi.advanceTimersByTimeAsync(0) - abort.abort() - await rejected - finish({ numPages: 1, loadingTask: { destroy } }) - await vi.advanceTimersByTimeAsync(0) - expect(destroy).toHaveBeenCalledTimes(2) - expect(read).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) } finally { vi.useRealTimers() } }) + + it("cancels a pending PDF load and releases a late document", async () => { + const destroy = vi.fn(async () => {}) + const read = vi.fn() + const abort = new AbortController() + let finish = (_value: unknown) => {} + const loading: LoadEngine = async () => ({ + getDocumentProxy: () => + new Promise((resolve) => { + finish = resolve + }), + extractText: read, + }) + const pending = extractText(pdf(), hint("cancelled.pdf"), loading, abort.signal) + const rejected = expect(pending).rejects.toMatchObject({ code: "cancelled" }) + await new Promise((resolve) => setTimeout(resolve, 0)) + abort.abort() + await rejected + finish({ numPages: 1, loadingTask: { destroy } }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(destroy).toHaveBeenCalledOnce() + expect(read).not.toHaveBeenCalled() + }) it("preserves spreadsheet cell addresses and PDF page spans", async () => { const workbook = new ExcelJS.Workbook() const sheet = workbook.addWorksheet("Synthetic Budget") diff --git a/src/attachments/extract.ts b/src/attachments/extract.ts index 356b2fc6c..948854c85 100644 --- a/src/attachments/extract.ts +++ b/src/attachments/extract.ts @@ -142,10 +142,7 @@ export const extractText = async ( if (kind) return readDocument(bytes, kind, signal) if (startsWith(bytes, "%PDF-")) { return withEngine(load, "unpdf", async (unpdf, extractor) => { - const deadline = new AbortController() - const timeout = setTimeout(() => deadline.abort(), 30_000) - timeout.unref() - const bounded = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal + const bounded = signal ?? new AbortController().signal let pdf: PdfDocument | undefined let closed = false const close = () => { @@ -158,7 +155,7 @@ export const extractText = async ( const aborted = new Promise((_, reject) => { abort = () => { close() - reject(new CliError(signal?.aborted ? "cancelled" : "timeout", "PDF text extraction stopped")) + reject(new CliError("cancelled", "PDF text extraction stopped")) } bounded.addEventListener("abort", abort, { once: true }) if (bounded.aborted) abort() @@ -175,7 +172,7 @@ export const extractText = async ( close() bounded.throwIfAborted() } - if (!Number.isSafeInteger(pdf.numPages) || pdf.numPages < 1 || pdf.numPages > 20) throw new ReaderLimit() + if (!Number.isSafeInteger(pdf.numPages) || pdf.numPages < 1) throw new ReaderLimit() return unpdf.extractText(pdf, { mergePages: false }) } const result = await Promise.race([reading(), aborted]) @@ -198,7 +195,6 @@ export const extractText = async ( ...(text.length > MAX_TEXT_CHARS ? { truncated: true } : {}), } } finally { - clearTimeout(timeout) bounded.removeEventListener("abort", abort) close() } diff --git a/src/attachments/pdf-preview.test.ts b/src/attachments/pdf-preview.test.ts index f019ec1c8..62a0cc25b 100644 --- a/src/attachments/pdf-preview.test.ts +++ b/src/attachments/pdf-preview.test.ts @@ -43,10 +43,14 @@ describe("PDF page previews for remote agents", () => { expect(fixture.destroy).toHaveBeenCalledTimes(1) expect(fixture.cleanup).toHaveBeenCalledTimes(1) }) + it("previews a requested page in a long document", async () => { + const fixture = fake({ pages: 100 }) + expect(await pdfPreview(pdf(), 99, undefined, fixture.load)).toMatchObject({ page: 99, pageCount: 100 }) + }) it("rejects invalid pages, page counts and dimensions before rendering", async () => { for (const page of [0, -1, 1.5, Infinity]) await expect(pdfPreview(pdf(), page)).rejects.toMatchObject({ code: "validation_error" }) - for (const pages of [0, 21, 1.5]) { + for (const pages of [0, 1.5]) { const fixture = fake({ pages }) await expect(pdfPreview(pdf(), 1, undefined, fixture.load)).rejects.toMatchObject({ code: "validation_error" }) expect(fixture.destroy).toHaveBeenCalledTimes(1) diff --git a/src/attachments/pdf-preview.ts b/src/attachments/pdf-preview.ts index ca8f817e7..8dae7bd03 100644 --- a/src/attachments/pdf-preview.ts +++ b/src/attachments/pdf-preview.ts @@ -61,8 +61,8 @@ export const pdfPreview = async ( }) signal?.addEventListener("abort", close, { once: true }) cancelled() - if (!Number.isSafeInteger(document.numPages) || document.numPages < 1 || document.numPages > 20) - throw new CliError("validation_error", "PDF previews support documents with 1–20 pages") + if (!Number.isSafeInteger(document.numPages) || document.numPages < 1) + throw new CliError("validation_error", "PDF page count must be a positive integer") if (page > document.numPages) throw new CliError("validation_error", "PDF page is past the end of the document") const selected = await document.getPage(page) let image: Buffer diff --git a/src/cli/bot/messages.ts b/src/cli/bot/messages.ts index 36f29f5f8..e2757ae64 100644 --- a/src/cli/bot/messages.ts +++ b/src/cli/bot/messages.ts @@ -81,7 +81,7 @@ const sendCommand = (bot: BotMessenger): Command => .option("--photo ", "attach a .jpg, .png or .webp as a photo; the text becomes its caption") .option("--as-file", "send the --file as a file to download, a video included") .option("--voice ", "send an Ogg Opus file as a voice message, alone, with no text") - .option("--allow-any-file", "send a file even from a hidden folder, ~/.ssh or this CLI's own folders") + .option("--allow-any-file", "send a file even from credential folders or this CLI's own folders") .action(async function (this: Command, chat: string, text: string | undefined) { const context = botContext(this, bot) const options = this.opts<{ diff --git a/src/cli/messenger/messages-send-command.ts b/src/cli/messenger/messages-send-command.ts index a8a933d42..3cf25f851 100644 --- a/src/cli/messenger/messages-send-command.ts +++ b/src/cli/messenger/messages-send-command.ts @@ -34,7 +34,7 @@ export const sendCommand = (messenger: Messenger): Command => { .option("--photo ", "attach a .jpg, .png or .webp as a photo; the text becomes its caption") .option("--as-file", "send the --file as a file to download, a video included") .option("--voice ", "send an Ogg Opus file as a voice message, alone, with no text") - .option("--allow-any-file", "send a file even from a hidden folder, ~/.ssh or this CLI's own folders") + .option("--allow-any-file", "send a file even from credential folders or this CLI's own folders") .option( "--at-time