Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ 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)

- 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.
Expand Down
2 changes: 2 additions & 0 deletions docs/cloud.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <hello@meterapp.co> (https://meterapp.co)",
Expand Down
50 changes: 50 additions & 0 deletions src/cloud/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,56 @@ async function failure(request: Promise<unknown>): Promise<CloudHttpError> {
}

describe("cloudRequest", () => {
it("cancels a chunked oversized answer before consuming its remaining data", async () => {
let cancelled = false;
let reads = 0;
const stream = new ReadableStream<Uint8Array>({
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<void>(() => {}); },
}, { 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" });
Expand Down
60 changes: 56 additions & 4 deletions src/cloud/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -25,6 +26,8 @@ export interface CloudRequestOptions {
/** Raw bytes instead of JSON (artifact chunks). */
raw?: { body: Uint8Array; contentType: string; headers?: Record<string, string> };
timeoutMs?: number;
/** Decoded response bytes; sync supplies its smaller protocol limit. */
maxResponseBytes?: number;
fetchImpl?: typeof fetch;
}

Expand Down Expand Up @@ -54,16 +57,20 @@ export async function cloudRequest<T = unknown>(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 {
Expand All @@ -82,3 +89,48 @@ export async function cloudRequest<T = unknown>(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<string> {
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<never>((_resolve, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
let chunk: Awaited<ReturnType<typeof reader.read>>;
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();
}
}
2 changes: 1 addition & 1 deletion src/cloud/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading