From 40a0f9385b5448ca870c8ad6a84a0fe807550d64 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 14 Oct 2026 14:29:29 -0400 Subject: [PATCH 1/2] Bound Cloud response reads and redact body transport errors --- CHANGELOG.md | 2 ++ docs/cloud.md | 2 ++ src/cloud/http.test.ts | 50 +++++++++++++++++++++++++++++++++++ src/cloud/http.ts | 60 +++++++++++++++++++++++++++++++++++++++--- src/cloud/link.ts | 2 +- 5 files changed, 111 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6d6694..a9fdc8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to skillhook, newest first. The format follows [Keep a Chang ## Unreleased +- Bound decoded Cloud response bodies (4 MiB for machine sync, 64 MiB for other calls), cancel oversized or stalled streams, and normalize/redact body-read errors like connection errors. This hardens transport buffering; it does not establish the cause of the reported native HTTP/2 stall. + ## 0.10.2 (2026-10-14) - Preserve public configuration in cloud uploads through the local `cloud.public_env` declaration, fixing GitHub repository slugs redacted inside job links. Unknown values and credential variables stay private; cloud commands cannot change this policy. diff --git a/docs/cloud.md b/docs/cloud.md index eb0b6b2..c721d07 100644 --- a/docs/cloud.md +++ b/docs/cloud.md @@ -1,5 +1,7 @@ # Skillhook Cloud +Cloud requests keep their timeout through response-body reading. Machine sync answers are limited to 4 MiB of decoded bytes; other Cloud API answers are limited to 64 MiB (large inline artifacts fit within this bound). Oversized streams are cancelled with a `too_large` error before JSON parsing. Partial or unreadable answers are not acknowledged as completed work; the existing reconciliation and retry rules still apply. Body-read failures receive the same credential redaction and transport error handling as connection failures. + [Skillhook Cloud](https://skillhook.dev) is the hosted control plane for machines running skillhook, by the same team: every webhook and job of every machine in one place, health of the CLIs and their MCP servers, replay, stats, a playground for skills, remote configuration from a browser or from an MCP client, an inbox of the questions agents ask people, alerts, teams and roles, and hosted webhook URLs that keep deliveries while a machine is asleep or has no public URL. Its own documentation is at https://skillhook.dev/docs (machines and pairing, hosted URLs, webhook history and replay, jobs and answers, alerts, teams, the API and its keys, the hosted MCP server, the CLI) and its plans at https://skillhook.dev/pricing; the Free plan covers two machines. It is a separate service (`MeterApp/skillhook-cloud`); this document is about the machine side. **Status.** The link is in this version: `skillhook cloud connect` pairs a machine, the running server keeps one outbound connection to the cloud, uploads what happens, runs the commands below as far as `cloud.mode` and the allow/deny lists permit, and delivers webhooks that arrived at the machine's hosted URLs. diff --git a/src/cloud/http.test.ts b/src/cloud/http.test.ts index 1919624..9ce2235 100644 --- a/src/cloud/http.test.ts +++ b/src/cloud/http.test.ts @@ -35,6 +35,56 @@ async function failure(request: Promise): Promise { } describe("cloudRequest", () => { + it("cancels a chunked oversized answer before consuming its remaining data", async () => { + let cancelled = false; + let reads = 0; + const stream = new ReadableStream({ + pull(controller) { + reads++; + controller.enqueue(new TextEncoder().encode("12345")); + }, + cancel() { cancelled = true; }, + }, { highWaterMark: 0 }); + const reply: typeof fetch = async () => new Response(stream); + expect(await failure(cloudRequest("https://cloud.example", "/api/agent/sync", { fetchImpl: reply, maxResponseBytes: 8 }))).toMatchObject({ code: "too_large" }); + expect(cancelled).toBe(true); + expect(reads).toBe(2); + }); + + it("counts bytes and decodes UTF-8 split across chunks at the exact limit", async () => { + const bytes = new TextEncoder().encode('{"text":"é"}'); + const reply: typeof fetch = async () => new Response(new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); + controller.close(); + }, + })); + expect((await cloudRequest("https://cloud.example", "/api/v1/me", { fetchImpl: reply, maxResponseBytes: bytes.length })).body).toEqual({ text: "é" }); + expect(await failure(cloudRequest("https://cloud.example", "/api/v1/me", { fetchImpl: reply, maxResponseBytes: bytes.length - 1 }))).toMatchObject({ code: "too_large" }); + }); + + it("times out after headers when the body stalls and cancels the producer", async () => { + let cancelled = false; + const reply: typeof fetch = async () => new Response(new ReadableStream({ cancel() { cancelled = true; } })); + expect(await failure(cloudRequest("https://cloud.example", "/api/agent/sync", { fetchImpl: reply, timeoutMs: 20 }))).toMatchObject({ status: 0, code: "timeout" }); + expect(cancelled).toBe(true); + }); + + it("normalizes and redacts errors from the response body, too", async () => { + const reply: typeof fetch = async () => new Response(new ReadableStream({ start(controller) { controller.error(new TypeError(`body refused Bearer ${KEY}`)); } })); + expect(await failure(cloudRequest("https://cloud.example", "/api/v1/me", { token: KEY, fetchImpl: reply }))).toMatchObject({ status: 0, code: "network", message: "body refused Bearer [redacted]" }); + }); + + it("times out an empty-chunk flood even when cancellation never settles", async () => { + let cancelled = false; + const reply: typeof fetch = async () => new Response(new ReadableStream({ + pull(controller) { controller.enqueue(new Uint8Array()); }, + cancel() { cancelled = true; return new Promise(() => {}); }, + }, { highWaterMark: 0 })); + expect(await failure(cloudRequest("https://cloud.example", "/api/agent/sync", { fetchImpl: reply, timeoutMs: 20 }))).toMatchObject({ code: "timeout" }); + expect(cancelled).toBe(true); + }); + it("reads the public API's problems as well as the agent API's errors", async () => { const problem = await answering(403, JSON.stringify({ type: "https://cloud.example/docs/api#forbidden", title: "forbidden", status: 403, code: "forbidden", detail: "Sending job.answer needs the member role.", request_id: "req_7" }), { "content-type": "application/problem+json" }); expect(await failure(cloudRequest(problem, "/api/v1/jobs", { method: "GET", token: KEY }))).toMatchObject({ status: 403, code: "forbidden", message: "Sending job.answer needs the member role.", requestId: "req_7" }); diff --git a/src/cloud/http.ts b/src/cloud/http.ts index 4521bf4..7c2f1e7 100644 --- a/src/cloud/http.ts +++ b/src/cloud/http.ts @@ -2,6 +2,7 @@ // protocol header, a timeout, JSON in and out. The token is never logged or included in an error message. import { VERSION } from "../version.js"; import { PROTOCOL_VERSION } from "./protocol.js"; +import { setImmediate } from "node:timers/promises"; export class CloudHttpError extends Error { constructor( @@ -25,6 +26,8 @@ export interface CloudRequestOptions { /** Raw bytes instead of JSON (artifact chunks). */ raw?: { body: Uint8Array; contentType: string; headers?: Record }; timeoutMs?: number; + /** Decoded response bytes; sync supplies its smaller protocol limit. */ + maxResponseBytes?: number; fetchImpl?: typeof fetch; } @@ -54,16 +57,20 @@ export async function cloudRequest(baseUrl: string, path: string, o headers["content-type"] = "application/json"; body = JSON.stringify(options.body); } + const signal = AbortSignal.timeout(options.timeoutMs ?? 30_000); let response: Response; + let text: string; try { - response = await fetchImpl(`${baseUrl}${path}`, { method: options.method ?? "POST", headers, body, signal: AbortSignal.timeout(options.timeoutMs ?? 30_000) }); + response = await fetchImpl(`${baseUrl}${path}`, { method: options.method ?? "POST", headers, body, signal }); + text = await boundedText(response, options.maxResponseBytes ?? 64 * 1024 * 1024, signal); } catch (error) { - const raw = error instanceof Error ? error.message : String(error); + if (error instanceof CloudHttpError) throw error; + const cause: unknown = signal.aborted ? signal.reason : error; + const raw = cause instanceof Error ? cause.message : String(cause); const message = options.token ? raw.replaceAll(options.token, "[redacted]") : raw; - const name = error instanceof Error ? error.name : ""; + const name = cause instanceof Error ? cause.name : ""; throw new CloudHttpError(0, name === "TimeoutError" ? "timeout" : name === "AbortError" ? "aborted" : "network", message); } - const text = await response.text(); let parsed: unknown = undefined; if (text) { try { @@ -82,3 +89,48 @@ export async function cloudRequest(baseUrl: string, path: string, o } return { status: response.status, body: parsed as T, headers: response.headers }; } + +/** Count decoded bytes, including chunked/compressed answers, before buffering or parsing JSON. */ +async function boundedText(response: Response, maxBytes: number, signal: AbortSignal): Promise { + if (!response.body) return ""; + const reader = response.body.getReader(); + let bytes = Buffer.alloc(0); + let size = 0; + let reads = 0; + try { + for (;;) { + signal.throwIfAborted(); + // Give timeout timers a turn even if a producer emits immediately resolved, empty chunks. + if (++reads % 1024 === 0) await setImmediate(undefined, { signal }); + let onAbort: () => void = () => {}; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }); + let chunk: Awaited>; + try { + chunk = await Promise.race([reader.read(), aborted]); + } finally { + signal.removeEventListener("abort", onAbort); + } + const { done, value } = chunk; + if (done) break; + const nextSize = size + value.byteLength; + if (nextSize > maxBytes) throw new CloudHttpError(response.status, "too_large", "Cloud response exceeds the byte limit"); + if (nextSize > bytes.length) { + const grown = Buffer.allocUnsafe(Math.min(maxBytes, Math.max(nextSize, 4096, bytes.length * 2))); + bytes.copy(grown, 0, 0, size); + bytes = grown; + } + bytes.set(value, size); + size = nextSize; + } + return bytes.toString("utf8", 0, size); + } catch (error) { + // A broken producer may never settle cancellation. Do not let it hold the sync loop. + void reader.cancel().catch(() => {}); + throw error; + } finally { + reader.releaseLock(); + } +} diff --git a/src/cloud/link.ts b/src/cloud/link.ts index 676bba8..e844236 100644 --- a/src/cloud/link.ts +++ b/src/cloud/link.ts @@ -653,7 +653,7 @@ export class CloudLink { try { const signal = this.inflight.signal; const fetchImpl = this.deps.fetchImpl ?? fetch; - const answer = await cloudRequest(url, "/api/agent/sync", { token, body: request, timeoutMs: options.timeoutMs, fetchImpl: (input, init) => fetchImpl(input, { ...init, signal: signal.aborted ? signal : anySignal([signal, init?.signal ?? undefined]) }) }); + const answer = await cloudRequest(url, "/api/agent/sync", { token, body: request, timeoutMs: options.timeoutMs, maxResponseBytes: LIMITS.max_sync_bytes, fetchImpl: (input, init) => fetchImpl(input, { ...init, signal: signal.aborted ? signal : anySignal([signal, init?.signal ?? undefined]) }) }); const parsed = SyncResponseSchema.safeParse(answer.body); if (!parsed.success) throw new CloudHttpError(answer.status, "protocol_error", "the cloud answered with something this version does not understand"); response = parsed.data; From 266262f6608f3e2260322d41df8dc4d334cefb28 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 14 Oct 2026 14:30:06 -0400 Subject: [PATCH 2/2] Release Skillhook 0.10.3 transport hardening --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 38ecf3f..12f21a9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "skillhook", "displayName": "skillhook", - "version": "0.10.2", + "version": "0.10.3", "description": "Turn this machine into a permanent, secure webhook endpoint that runs Agent Skills with Claude Code or Codex, and run your whole Skillhook Cloud organisation from the agent. Three skills teach the agent to install and expose skillhook, to write good webhook skills and to operate the fleet; two MCP servers manage this machine (skills, secrets, jobs, exposure) and, with an organisation API key, every machine as the Skillhook Cloud dashboard does (what needs a person, failures, stats, answers, replays, runs, hosted URLs).", "author": { "name": "Meter", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 398ea87..122a895 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skillhook", - "version": "0.10.2", + "version": "0.10.3", "description": "Turn this machine into a permanent, secure webhook endpoint that runs Agent Skills with Claude Code or Codex, and run your whole Skillhook Cloud organisation from the agent. Three skills teach the agent to install and expose skillhook, to write good webhook skills and to operate the fleet; two MCP servers manage this machine (skills, secrets, jobs, exposure) and, with an organisation API key, every machine as the Skillhook Cloud dashboard does (what needs a person, failures, stats, answers, replays, runs, hosted URLs).", "author": { "name": "Meter", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 0b12558..219bfb7 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skillhook", - "version": "0.10.2", + "version": "0.10.3", "description": "Turn this machine into a permanent, secure webhook endpoint that runs Agent Skills with Claude Code or Codex, and run your whole Skillhook Cloud organisation from the agent. Three skills teach the agent to install and expose skillhook, to write good webhook skills and to operate the fleet; two MCP servers manage this machine (skills, secrets, jobs, exposure) and, with an organisation API key, every machine as the Skillhook Cloud dashboard does (what needs a person, failures, stats, answers, replays, runs, hosted URLs).", "author": { "name": "Meter", diff --git a/CHANGELOG.md b/CHANGELOG.md index a9fdc8e..855537e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to skillhook, newest first. The format follows [Keep a Chang ## Unreleased +## 0.10.3 (2026-10-14) + - Bound decoded Cloud response bodies (4 MiB for machine sync, 64 MiB for other calls), cancel oversized or stalled streams, and normalize/redact body-read errors like connection errors. This hardens transport buffering; it does not establish the cause of the reported native HTTP/2 stall. ## 0.10.2 (2026-10-14) diff --git a/package-lock.json b/package-lock.json index e5dac75..de31431 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meterapp/skillhook", - "version": "0.10.2", + "version": "0.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meterapp/skillhook", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/server": "^2.2.0", diff --git a/package.json b/package.json index 3a36986..c49ea0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@meterapp/skillhook", - "version": "0.10.2", + "version": "0.10.3", "description": "Make your Agent Skills reactive. A permanent, secure webhook endpoint on your own machine that runs SKILL.md files with Claude Code or Codex the moment something happens, plus version-controlled schedules for the work that has no trigger. Webhook in, agent out.", "license": "MIT", "author": "Meter (https://meterapp.co)",