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
6 changes: 3 additions & 3 deletions docs/.generated/config-baseline.sha256
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
f5a5855ddd7aa8c23a732f257eceaa20fd163b1d5f342c909f4aef15aa8643cf config-baseline.json
b8dffdb1a328aaf728a0707ab04d21c65f1a225a2360042e10832aa608699716 config-baseline.core.json
26afb40d889df462d5886e6fa0ecd09ce6bae12387836b59a2489c696a8ff3a1 config-baseline.json
00242f93938579d3ff3130f8d42e08a423e43dc7a73e3b5e5afb45b80c78d25a config-baseline.core.json
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json
21ce3d97ac3a83323fa29ddc35045c04f6eae3040c2c5515077f543b4608fd12 config-baseline.plugin.json
19 changes: 19 additions & 0 deletions docs/cli/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
openclaw plugins marketplace list <marketplace> --json
openclaw plugins marketplace refresh
openclaw plugins marketplace refresh --feed-profile clawhub-public --json
openclaw plugins marketplace refresh --feed-url https://clawhub.ai/v1/feeds/plugins --expected-sha256 <sha256>
openclaw plugins init my-tool --name "My Tool"
openclaw plugins init my-provider --name "My Provider" --type provider
openclaw plugins init my-provider --name "My Provider" --type provider --directory ./my-provider
Expand Down Expand Up @@ -521,10 +524,26 @@ Use `plugins registry` to inspect whether the persisted registry is present, cur
```bash
openclaw plugins marketplace list <source>
openclaw plugins marketplace list <source> --json
openclaw plugins marketplace refresh
openclaw plugins marketplace refresh --feed-profile <name>
openclaw plugins marketplace refresh --feed-url <url>
openclaw plugins marketplace refresh --expected-sha256 <sha256> --json
```

Marketplace list accepts a local marketplace path, a `marketplace.json` path, a GitHub shorthand like `owner/repo`, a GitHub repo URL, or a git URL. `--json` prints the resolved source label plus the parsed marketplace manifest and plugin entries.

Marketplace refresh loads a hosted OpenClaw marketplace feed and persists the
validated response as the local hosted-feed snapshot. Without options, it uses
the configured default feed profile. Use `--feed-profile <name>` to refresh a
specific configured profile, `--feed-url <url>` to refresh an explicit hosted
feed URL, `--expected-sha256 <sha256>` to require a matching payload checksum
(`sha256:<hex>` or a bare 64-character hex digest), and `--json` for
machine-readable output. Explicit hosted feed URLs must not include
credentials, query strings, or fragments. Unpinned refreshes can report a
hosted snapshot or bundled fallback result without failing the command. Pinned
refreshes fail unless they accept a fresh hosted payload, and successful hosted
refreshes fail if OpenClaw cannot persist the validated snapshot.

## Related

- [Building plugins](/plugins/building-plugins)
Expand Down
6 changes: 2 additions & 4 deletions docs/plugins/sdk-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,9 @@ two-party event loops that do not go through the shared inbound reply runner.
await api.runtime.agent.ensureAgentWorkspace(cfg);

// Run an embedded agent turn
const agentDir = api.runtime.agent.resolveAgentDir(cfg);
const result = await api.runtime.agent.runEmbeddedAgent({
sessionId: "my-plugin:task-1",
runId: crypto.randomUUID(),
sessionFile: path.join(agentDir, "sessions", "my-plugin-task-1.jsonl"),
workspaceDir: api.runtime.agent.resolveAgentWorkspaceDir(cfg),
prompt: "Summarize the latest changes",
timeoutMs: api.runtime.agent.resolveAgentTimeoutMs(cfg),
Expand Down Expand Up @@ -166,9 +164,9 @@ two-party event loops that do not go through the shared inbound reply runner.

Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.

For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Pass `sessionFile` only when adapting code that already receives an active transcript artifact and needs each helper to operate on that same artifact.
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Passing `sessionFile`, using `resolveSessionTranscriptLegacyFileTarget(...)`, or importing low-level `appendSessionTranscriptMessage(...)` / `emitSessionTranscriptUpdate(...)` from `openclaw/plugin-sdk/agent-harness-runtime` is deprecated; those paths exist only for legacy code that already receives an active transcript artifact.

`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, `resolveSessionFilePath(...)`, and `resolveAndPersistSessionFile(...)` are deprecated compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers and transcript identity helpers.

</Accordion>
<Accordion title="api.runtime.agent.defaults">
Expand Down
10 changes: 5 additions & 5 deletions docs/providers/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ as one OpenCode setup.

<Tabs>
<Tab title="Zen catalog">
**Best for:** the curated OpenCode multi-model proxy (Claude, GPT, Gemini).
**Best for:** the curated OpenCode multi-model proxy (Claude, GPT, Gemini, GLM).

<Steps>
<Step title="Run onboarding">
Expand Down Expand Up @@ -92,10 +92,10 @@ as one OpenCode setup.

### Zen

| Property | Value |
| ---------------- | ----------------------------------------------------------------------- |
| Runtime provider | `opencode` |
| Example models | `opencode/claude-opus-4-6`, `opencode/gpt-5.5`, `opencode/gemini-3-pro` |
| Property | Value |
| ---------------- | --------------------------------------------------------------------------------------------- |
| Runtime provider | `opencode` |
| Example models | `opencode/claude-opus-4-6`, `opencode/gpt-5.5`, `opencode/gemini-3.1-pro`, `opencode/glm-5.2` |

### Go

Expand Down
72 changes: 72 additions & 0 deletions extensions/discord/src/internal/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,78 @@ describe("RequestClient", () => {
expect(metrics.invalidRequestCountByStatus).toEqual({ 403: 1 });
});

it("bounds oversized REST response bodies instead of buffering them unbounded", async () => {
const encoder = new TextEncoder();
let pullCount = 0;
let cancelCount = 0;
const fetchSpy = vi.fn(
async () =>
new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
pullCount += 1;
// Flood far past the cap so an unbounded reader would OOM.
controller.enqueue(encoder.encode("x".repeat(4 * 1024 * 1024)));
},
cancel() {
cancelCount += 1;
},
}),
{ status: 200 },
),
);
const client = new RequestClient("test-token", { fetch: fetchSpy, queueRequests: false });

await expect(client.get("/channels/c1/messages")).rejects.toThrow(
/Discord REST response body exceeds 8388608 bytes/,
);
// The reader was cancelled at the cap rather than draining the whole flood:
// only a handful of 4 MiB chunks are pulled before the cap is hit.
expect(cancelCount).toBe(1);
expect(pullCount).toBeLessThanOrEqual(4);
});

it("aborts stalled REST response bodies after the idle timeout", async () => {
const encoder = new TextEncoder();
let cancelReason: unknown;
const fetchSpy = vi.fn(
async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
// Emit a partial chunk, then stall forever so the idle timeout
// (request timeout) must fire and cancel the stream.
controller.enqueue(encoder.encode("partial payload"));
},
cancel(reason) {
cancelReason = reason;
},
}),
{ status: 200 },
),
);
const client = new RequestClient("test-token", {
fetch: fetchSpy,
queueRequests: false,
timeout: 50,
});

await expect(client.get("/channels/c1/messages")).rejects.toThrow(
"Discord REST response stalled: no data received for 50ms",
);
expect(cancelReason).toBeInstanceOf(Error);
expect((cancelReason as Error).message).toBe(
"Discord REST response stalled: no data received for 50ms",
);
});

it("still parses normal-sized REST response payloads under the cap", async () => {
const fetchSpy = vi.fn(async () => createJsonResponse({ id: "channel", name: "general" }));
const client = new RequestClient("test-token", { fetch: fetchSpy, queueRequests: false });

await expect(client.get("/channels/c1")).resolves.toEqual({ id: "channel", name: "general" });
});

it("serializes message multipart uploads with payload_json", () => {
const headers = new Headers();
const body = serializeRequestBody(
Expand Down
21 changes: 20 additions & 1 deletion extensions/discord/src/internal/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
parseFiniteNumber,
resolveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { serializeRequestBody } from "./rest-body.js";
import {
DiscordError,
Expand Down Expand Up @@ -89,6 +90,24 @@ const defaultLaneOptions: Record<RestRequestPriority, { staleAfterMs?: number; w
background: { staleAfterMs: 20_000, weight: 1 },
};

// Cap the REST response body well above any legitimate Discord JSON payload
// (bulk message/member fetches stay in the low hundreds of KB) so a controlled
// or hijacked endpoint cannot flood the body into an unbounded buffer (OOM).
const DISCORD_REST_RESPONSE_BODY_MAX_BYTES = 8 * 1024 * 1024;

async function readResponseBodyText(response: Response, idleTimeoutMs: number): Promise<string> {
const buffer = await readResponseWithLimit(response, DISCORD_REST_RESPONSE_BODY_MAX_BYTES, {
chunkTimeoutMs: idleTimeoutMs,
onOverflow: ({ size }) =>
new Error(
`Discord REST response body exceeds ${DISCORD_REST_RESPONSE_BODY_MAX_BYTES} bytes (received ${size})`,
),
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(`Discord REST response stalled: no data received for ${chunkTimeoutMs}ms`),
});
return buffer.toString("utf8");
}

function coerceResponseBody(raw: string): unknown {
if (!raw) {
return undefined;
Expand Down Expand Up @@ -249,7 +268,7 @@ export class RequestClient {
body: await normalizeFetchBody(body, headers),
signal,
});
const text = await response.text();
const text = await readResponseBodyText(response, this.options.timeout ?? 15_000);
const parsed = coerceResponseBody(text);
this.scheduler.recordResponse(routeKey, path, response, parsed);
if (response.status === 204) {
Expand Down
62 changes: 62 additions & 0 deletions extensions/mattermost/src/mattermost/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,68 @@ describe("createMattermostClient", () => {
expect(release).toHaveBeenCalledTimes(1);
});

it("bounds and cancels oversized guarded Mattermost success JSON bodies", async () => {
const release = vi.fn(async () => {});
let canceled = false;
let pulled = 0;
const oversizeChunk = new Uint8Array(2 * 1024 * 1024).fill(0x7b); // 2 MiB of '{'
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pulled += 1;
// Flood far past the 16 MiB JSON cap; an unbounded reader would buffer
// the whole stream before parsing.
controller.enqueue(oversizeChunk);
},
cancel() {
canceled = true;
},
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
release,
});
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});

let caught: Error | undefined;
try {
await client.request("/users/me");
} catch (error) {
caught = error as Error;
}

expect(caught?.message).toContain("JSON response exceeds 16777216 bytes");
// The reader is cancelled at the cap instead of draining the flood: ~8
// chunks of 2 MiB reach the 16 MiB ceiling, never the unbounded tail.
expect(canceled).toBe(true);
expect(pulled).toBeLessThanOrEqual(12);
expect(release).toHaveBeenCalledTimes(1);
});

it("rejects oversized guarded Mattermost success text bodies instead of truncating", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedResponse(`${"plain success ".repeat(7000)}tail`, {
status: 200,
headers: { "content-type": "text/plain" },
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: tracked.response, release });
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});

await expect(client.request("/users/me")).rejects.toThrow(
"Mattermost API /users/me: text response exceeds 65536 bytes",
);
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});

it("releases guarded Mattermost responses when upstream body reads fail", async () => {
const release = vi.fn(async () => {});
const stream = new ReadableStream<Uint8Array>({
Expand Down
30 changes: 26 additions & 4 deletions extensions/mattermost/src/mattermost/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Mattermost plugin module implements client behavior.
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import {
fetchWithSsrFGuard,
Expand All @@ -13,6 +17,13 @@ import {
import { z } from "zod";

const MATTERMOST_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
// Mattermost REST control-plane JSON (posts, users, channels, file-upload
// results) stays well under a megabyte; cap successful JSON the same way the
// shared provider path is capped so an untrusted/self-hosted homeserver cannot
// stream an unbounded body into the runtime before parsing.
// Non-JSON success bodies are a rare fallback (the API is JSON-first); keep a
// generous text budget but still bound it instead of buffering the whole stream.
const MATTERMOST_TEXT_RESPONSE_LIMIT_BYTES = 64 * 1024;
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);

export type MattermostFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
Expand Down Expand Up @@ -84,6 +95,14 @@ function buildMattermostApiUrl(baseUrl: string, path: string): string {
return `${normalized}/api/v4${suffix}`;
}

async function readMattermostSuccessText(res: Response, path: string): Promise<string> {
const bytes = await readResponseWithLimit(res, MATTERMOST_TEXT_RESPONSE_LIMIT_BYTES, {
onOverflow: ({ maxBytes }) =>
new Error(`Mattermost API ${path}: text response exceeds ${maxBytes} bytes`),
});
return new TextDecoder().decode(bytes);
}

export async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? "";
if (!res.body) {
Expand Down Expand Up @@ -214,9 +233,9 @@ export function createMattermostClient(params: {

const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return (await res.json()) as T;
return await readProviderJsonResponse<T>(res, `Mattermost API ${path}`);
}
return (await res.text()) as T;
return (await readMattermostSuccessText(res, path)) as T;
};

return { baseUrl, apiBaseUrl, token, request, fetchImpl };
Expand Down Expand Up @@ -679,7 +698,10 @@ export async function uploadMattermostFile(
const detail = await readMattermostError(res);
throw new Error(`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`);
}
const data = (await res.json()) as { file_infos?: MattermostFileInfo[] };
const data = await readProviderJsonResponse<{ file_infos?: MattermostFileInfo[] }>(
res,
"Mattermost API /files",
);
const info = data.file_infos?.[0];
if (!info?.id) {
throw new Error("Mattermost file upload failed");
Expand Down
31 changes: 31 additions & 0 deletions extensions/mattermost/src/mattermost/probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,37 @@ describe("probeMattermost", () => {
expect(mockRelease).toHaveBeenCalledTimes(1);
});

it("bounds and cancels oversized probe success JSON bodies", async () => {
let canceled = false;
let pulled = 0;
const oversizeChunk = new Uint8Array(2 * 1024 * 1024).fill(0x7b); // 2 MiB of "{"
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pulled += 1;
controller.enqueue(oversizeChunk);
},
cancel() {
canceled = true;
},
});
mockFetchGuard.mockResolvedValueOnce({
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
release: mockRelease,
});

const result = await probeMattermost("https://mm.example.com", "bot-token");

expect(result.ok).toBe(false);
expect(result.status).toBeNull();
expect(result.error).toContain("JSON response exceeds 16777216 bytes");
expect(canceled).toBe(true);
expect(pulled).toBeLessThanOrEqual(12);
expect(mockRelease).toHaveBeenCalledTimes(1);
});

it("forwards allowPrivateNetwork to the SSRF guard policy", async () => {
mockFetchGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify({ id: "bot-1" }), {
Expand Down
Loading
Loading