Skip to content

Commit abd5ec9

Browse files
authored
fix(runtime): harden dependency install surfaces (#71997)
* fix(runtime): harden dependency surfaces * fix(runtime): harden dependency install surfaces * fix(runtime): address dependency surface review * fix(runtime): address dependency surface review * fix(channels): avoid read-only plugin loader cycle * fix(channels): allow optional read-only loader workspace * test(commands): refresh current main checks * test(commands): keep provider metadata mock unique * test(commands): keep doctor security read-only mock unique
1 parent eb6b356 commit abd5ec9

14 files changed

Lines changed: 780 additions & 112 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ Docs: https://docs.openclaw.ai
111111
- Installer: warn when multiple npm global roots contain OpenClaw installs, showing active Node/npm/openclaw plus each install path and version so stale version-manager installs are visible. Fixes #40839. Thanks @zhixianio.
112112
- Cron/tasks: recover completed cron task ledger records from durable run logs and job state before marking them `lost`, reducing false `backing session missing` audit errors for isolated cron runs and keeping offline CLI audit from treating its empty local cron active-job set as authoritative. Fixes #71963.
113113
- Docker: copy patched dependency files into runtime images so downstream `pnpm install` layers keep working. Fixes #69224. Thanks @gucasbrg.
114+
- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc.
115+
- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc.
116+
- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc.
117+
- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc.
118+
- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc.
114119
- Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker.
115120
- Telegram: include native quote excerpts automatically for threaded replies and reply tags when the original Telegram text is available, without adding another config knob. Fixes #6975. Thanks @rex05ai.
116121
- Node/Linux: make `openclaw node install` enable and restart the `openclaw-node` systemd unit instead of the gateway unit on node-only VMs. Fixes #68287. Thanks @dlebee-agent.
Lines changed: 218 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
1-
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2-
3-
const fetchWithTimeoutMock = vi.fn();
4-
const resolveFetchMock = vi.fn();
5-
6-
vi.mock("openclaw/plugin-sdk/fetch-runtime", () => ({
7-
resolveFetch: (...args: unknown[]) => resolveFetchMock(...args),
8-
}));
1+
import { Buffer } from "node:buffer";
2+
import { once } from "node:events";
3+
import http, { type IncomingMessage, type ServerResponse } from "node:http";
4+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
95

106
vi.mock("openclaw/plugin-sdk/core", async () => {
117
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/core")>(
@@ -17,47 +13,91 @@ vi.mock("openclaw/plugin-sdk/core", async () => {
1713
};
1814
});
1915

20-
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
21-
fetchWithTimeout: (...args: unknown[]) => fetchWithTimeoutMock(...args),
22-
}));
23-
16+
let signalCheck: typeof import("./client.js").signalCheck;
2417
let signalRpcRequest: typeof import("./client.js").signalRpcRequest;
18+
let streamSignalEvents: typeof import("./client.js").streamSignalEvents;
2519

26-
function rpcResponse(body: unknown, status = 200): Response {
27-
if (typeof body === "string") {
28-
return new Response(body, { status });
20+
const servers: http.Server[] = [];
21+
22+
async function readRequestBody(req: IncomingMessage): Promise<string> {
23+
const chunks: Buffer[] = [];
24+
for await (const chunk of req as AsyncIterable<Buffer | string>) {
25+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
2926
}
30-
return new Response(JSON.stringify(body), { status });
27+
return Buffer.concat(chunks).toString("utf8");
3128
}
3229

33-
describe("signalRpcRequest", () => {
34-
beforeAll(async () => {
35-
({ signalRpcRequest } = await import("./client.js"));
30+
async function withSignalServer(
31+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
32+
): Promise<string> {
33+
const server = http.createServer((req, res) => {
34+
void Promise.resolve(handler(req, res)).catch((error: unknown) => {
35+
res.writeHead(500, { "Content-Type": "text/plain" });
36+
res.end(error instanceof Error ? error.message : String(error));
37+
});
3638
});
39+
servers.push(server);
40+
server.listen(0, "127.0.0.1");
41+
await once(server, "listening");
42+
const address = server.address();
43+
if (!address || typeof address === "string") {
44+
throw new Error("missing test server address");
45+
}
46+
return `http://127.0.0.1:${address.port}`;
47+
}
3748

38-
beforeEach(() => {
39-
vi.clearAllMocks();
40-
resolveFetchMock.mockReturnValue(vi.fn());
41-
});
49+
beforeAll(async () => {
50+
({ signalCheck, signalRpcRequest, streamSignalEvents } = await import("./client.js"));
51+
});
4252

53+
afterEach(async () => {
54+
await Promise.all(
55+
servers.splice(0).map(
56+
(server) =>
57+
new Promise<void>((resolve, reject) => {
58+
server.close((error) => {
59+
if (error) {
60+
reject(error);
61+
return;
62+
}
63+
resolve();
64+
});
65+
}),
66+
),
67+
);
68+
});
69+
70+
describe("signalRpcRequest", () => {
4371
it("returns parsed RPC result", async () => {
44-
fetchWithTimeoutMock.mockResolvedValueOnce(
45-
rpcResponse({ jsonrpc: "2.0", result: { version: "0.13.22" }, id: "test-id" }),
46-
);
72+
const baseUrl = await withSignalServer(async (req, res) => {
73+
expect(req.method).toBe("POST");
74+
expect(req.url).toBe("/api/v1/rpc");
75+
expect(req.headers["content-type"]).toBe("application/json");
76+
expect(JSON.parse(await readRequestBody(req))).toEqual({
77+
jsonrpc: "2.0",
78+
method: "version",
79+
id: "test-id",
80+
});
81+
res.writeHead(200, { "Content-Type": "application/json" });
82+
res.end(JSON.stringify({ jsonrpc: "2.0", result: { version: "0.13.22" }, id: "test-id" }));
83+
});
4784

4885
const result = await signalRpcRequest<{ version: string }>("version", undefined, {
49-
baseUrl: "http://127.0.0.1:8080",
86+
baseUrl,
5087
});
5188

5289
expect(result).toEqual({ version: "0.13.22" });
5390
});
5491

5592
it("throws a wrapped error when RPC response JSON is malformed", async () => {
56-
fetchWithTimeoutMock.mockResolvedValueOnce(rpcResponse("not-json", 502));
93+
const baseUrl = await withSignalServer((_req, res) => {
94+
res.writeHead(502, { "Content-Type": "text/plain" });
95+
res.end("not-json");
96+
});
5797

5898
await expect(
5999
signalRpcRequest("version", undefined, {
60-
baseUrl: "http://127.0.0.1:8080",
100+
baseUrl,
61101
}),
62102
).rejects.toMatchObject({
63103
message: "Signal RPC returned malformed JSON (status 502)",
@@ -66,12 +106,159 @@ describe("signalRpcRequest", () => {
66106
});
67107

68108
it("throws when RPC response envelope has neither result nor error", async () => {
69-
fetchWithTimeoutMock.mockResolvedValueOnce(rpcResponse({ jsonrpc: "2.0", id: "test-id" }));
109+
const baseUrl = await withSignalServer((_req, res) => {
110+
res.writeHead(200, { "Content-Type": "application/json" });
111+
res.end(JSON.stringify({ jsonrpc: "2.0", id: "test-id" }));
112+
});
70113

71114
await expect(
72115
signalRpcRequest("version", undefined, {
73-
baseUrl: "http://127.0.0.1:8080",
116+
baseUrl,
74117
}),
75118
).rejects.toThrow("Signal RPC returned invalid response envelope (status 200)");
76119
});
120+
121+
it("rejects credentialed base URLs", async () => {
122+
await expect(
123+
signalRpcRequest("version", undefined, {
124+
baseUrl: "http://user:[email protected]:8080",
125+
}),
126+
).rejects.toThrow("Signal base URL must not include credentials");
127+
});
128+
129+
it("rejects oversized RPC responses", async () => {
130+
const baseUrl = await withSignalServer((_req, res) => {
131+
res.writeHead(200, { "Content-Type": "application/json" });
132+
res.end("x".repeat(1_048_577));
133+
});
134+
135+
await expect(
136+
signalRpcRequest("version", undefined, {
137+
baseUrl,
138+
}),
139+
).rejects.toThrow("Signal HTTP response exceeded size limit");
140+
});
141+
142+
it("uses an absolute deadline for slow-drip RPC responses", async () => {
143+
const baseUrl = await withSignalServer((_req, res) => {
144+
res.writeHead(200, { "Content-Type": "application/json" });
145+
const interval = setInterval(() => {
146+
res.write(" ");
147+
}, 5);
148+
res.on("close", () => clearInterval(interval));
149+
});
150+
151+
await expect(
152+
signalRpcRequest("version", undefined, {
153+
baseUrl,
154+
timeoutMs: 25,
155+
}),
156+
).rejects.toThrow("Signal HTTP exceeded deadline after 25ms");
157+
});
158+
});
159+
160+
describe("signalCheck", () => {
161+
it("returns ok for a healthy signal-cli check", async () => {
162+
const baseUrl = await withSignalServer((req, res) => {
163+
expect(req.method).toBe("GET");
164+
expect(req.url).toBe("/api/v1/check");
165+
res.writeHead(204);
166+
res.end();
167+
});
168+
169+
await expect(signalCheck(baseUrl)).resolves.toEqual({ ok: true, status: 204, error: null });
170+
});
171+
172+
it("returns an HTTP status failure for unhealthy checks", async () => {
173+
const baseUrl = await withSignalServer((_req, res) => {
174+
res.writeHead(503);
175+
res.end("down");
176+
});
177+
178+
await expect(signalCheck(baseUrl)).resolves.toEqual({
179+
ok: false,
180+
status: 503,
181+
error: "HTTP 503",
182+
});
183+
});
184+
});
185+
186+
describe("streamSignalEvents", () => {
187+
it("streams events through node http instead of fetch", async () => {
188+
const events: Array<import("./client.js").SignalSseEvent> = [];
189+
const baseUrl = await withSignalServer((req, res) => {
190+
expect(req.url).toBe("/api/v1/events?account=%2B15555550123");
191+
expect(req.headers.accept).toBe("text/event-stream");
192+
res.writeHead(200, { "Content-Type": "text/event-stream" });
193+
res.end('id: 42\nevent: message\ndata: {"group":true}\n\n');
194+
});
195+
196+
await streamSignalEvents({
197+
baseUrl,
198+
account: "+15555550123",
199+
onEvent: (event) => events.push(event),
200+
});
201+
202+
expect(events).toEqual([{ id: "42", event: "message", data: '{"group":true}' }]);
203+
});
204+
205+
it("reports HTTP status failures from the event stream", async () => {
206+
const baseUrl = await withSignalServer((_req, res) => {
207+
res.writeHead(503, "Unavailable");
208+
res.end("down");
209+
});
210+
211+
await expect(
212+
streamSignalEvents({
213+
baseUrl,
214+
onEvent: () => {},
215+
}),
216+
).rejects.toThrow("Signal SSE failed (503 Unavailable)");
217+
});
218+
219+
it("rejects event streams that do not send headers before the deadline", async () => {
220+
const baseUrl = await withSignalServer(() => {
221+
// Leave the request open without response headers.
222+
});
223+
224+
await expect(
225+
streamSignalEvents({
226+
baseUrl,
227+
timeoutMs: 25,
228+
onEvent: () => {},
229+
}),
230+
).rejects.toThrow("Signal SSE connection timed out after 25ms");
231+
});
232+
233+
it("rejects oversized SSE line buffers by byte size", async () => {
234+
const baseUrl = await withSignalServer((_req, res) => {
235+
res.writeHead(200, { "Content-Type": "text/event-stream" });
236+
res.end(`data: ${"🙂".repeat(262_145)}`);
237+
});
238+
239+
await expect(
240+
streamSignalEvents({
241+
baseUrl,
242+
onEvent: () => {},
243+
}),
244+
).rejects.toThrow("Signal SSE buffer exceeded size limit");
245+
});
246+
247+
it("rejects oversized SSE events split across smaller data lines", async () => {
248+
const baseUrl = await withSignalServer((_req, res) => {
249+
res.writeHead(200, { "Content-Type": "text/event-stream" });
250+
const line = `data: ${"x".repeat(4096)}\n`;
251+
for (let index = 0; index < 260; index += 1) {
252+
res.write(line);
253+
}
254+
res.end();
255+
});
256+
257+
await expect(
258+
streamSignalEvents({
259+
baseUrl,
260+
onEvent: () => {},
261+
}),
262+
).rejects.toThrow("Signal SSE event data exceeded size limit");
263+
});
77264
});

0 commit comments

Comments
 (0)