mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(lmstudio): redact reflected model-load credentials (#128561)
* test(lmstudio): add reflected credential redaction regression * fix(lmstudio): redact exact outbound credentials in model load error body * fix(lmstudio): suppress truncated model load error bodies before redaction * fix(lmstudio): restore redactToolPayloadText import after rebase * test(lmstudio): add real HTTP-server credential redaction proof * fix(lmstudio): harden reflected model-load credentials Co-authored-by: xialonglee <li.xialong@xydigit.com> * test(lmstudio): isolate reflected model-load transport proof --------- Co-authored-by: xialonglee <li.xialong@xydigit.com>
This commit is contained in:
committed by
GitHub
parent
4dc083dc29
commit
62aca9a785
@@ -1,13 +1,13 @@
|
||||
// Lmstudio plugin module implements models.fetch behavior.
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
|
||||
import { createSubsystemLogger, redactToolPayloadText } from "openclaw/plugin-sdk/logging-core";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
readProviderJsonArrayFieldResponse,
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup";
|
||||
import { readResponseTextPrefix } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { asPositiveSafeInteger } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./defaults.js";
|
||||
@@ -24,6 +24,24 @@ import { buildLmstudioAuthHeaders } from "./runtime.js";
|
||||
const log = createSubsystemLogger("extensions/lmstudio/models");
|
||||
const LMSTUDIO_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
function redactLmstudioLoadError(value: string, headers: Record<string, string> | undefined) {
|
||||
const credentials = Object.entries(headers ?? {})
|
||||
.filter(([name]) => name.toLowerCase() !== "content-type")
|
||||
.flatMap(([name, header]) => {
|
||||
const normalized = header.trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
return name.toLowerCase() === "authorization"
|
||||
? [normalized, normalized.replace(/^\S+\s+/u, "")]
|
||||
: [normalized];
|
||||
})
|
||||
.toSorted((left, right) => right.length - left.length);
|
||||
return redactToolPayloadText(
|
||||
credentials.reduce((redacted, credential) => redacted.replaceAll(credential, "***"), value),
|
||||
);
|
||||
}
|
||||
|
||||
type LmstudioLoadResponse = {
|
||||
status?: string;
|
||||
};
|
||||
@@ -275,15 +293,16 @@ export async function ensureLmstudioModelLoaded(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const requestHeaders = buildLmstudioAuthHeaders({
|
||||
apiKey: params.apiKey,
|
||||
headers: params.headers,
|
||||
json: true,
|
||||
});
|
||||
const { response, release } = await fetchLmstudioEndpoint({
|
||||
url: `${baseUrl}/api/v1/models/load`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: buildLmstudioAuthHeaders({
|
||||
apiKey: params.apiKey,
|
||||
headers: params.headers,
|
||||
json: true,
|
||||
}),
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify({
|
||||
model: canonicalModelKey,
|
||||
// Ask LM Studio to load with our default target, capped to the model's own limit.
|
||||
@@ -297,9 +316,15 @@ export async function ensureLmstudioModelLoaded(params: {
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const body = await readResponseTextLimited(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES);
|
||||
const bodyRead = await readResponseTextPrefix(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES, {
|
||||
chunkTimeoutMs: 10_000,
|
||||
});
|
||||
// A truncated credential cannot be identified safely; drop the entire diagnostic.
|
||||
const detail = bodyRead.truncated
|
||||
? ""
|
||||
: redactLmstudioLoadError(bodyRead.text, requestHeaders);
|
||||
throw new Error(
|
||||
`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`,
|
||||
`LM Studio model load failed (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
// Read the success body through the shared byte-capped reader so a misbehaving
|
||||
@@ -310,7 +335,8 @@ export async function ensureLmstudioModelLoaded(params: {
|
||||
"LM Studio model load",
|
||||
);
|
||||
if (typeof payload.status === "string" && payload.status.toLowerCase() !== "loaded") {
|
||||
throw new Error(`LM Studio model load returned unexpected status: ${payload.status}`);
|
||||
const status = redactLmstudioLoadError(payload.status, requestHeaders);
|
||||
throw new Error(`LM Studio model load returned unexpected status: ${status}`);
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createServer } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ensureLmstudioModelLoaded } from "./models.fetch.js";
|
||||
|
||||
describe("LM Studio model-load error transport", () => {
|
||||
it("redacts actual outbound credentials reflected by a model-load HTTP server", async () => {
|
||||
let responseStatus = 502;
|
||||
const server = createServer((request, response) => {
|
||||
if (request.method === "GET") {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const authorization = request.headers.authorization;
|
||||
const proxyAuthorization = request.headers["x-proxy-auth"];
|
||||
if (typeof authorization !== "string" || typeof proxyAuthorization !== "string") {
|
||||
response.writeHead(500);
|
||||
response.end("missing expected authentication headers");
|
||||
return;
|
||||
}
|
||||
const reflected = `upstream rejected ${authorization}; proxy ${proxyAuthorization}`;
|
||||
response.writeHead(responseStatus, { "Content-Type": "application/json" });
|
||||
response.end(responseStatus === 200 ? JSON.stringify({ status: reflected }) : reflected);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("LM Studio test server did not bind to a local port");
|
||||
}
|
||||
for (const [status, expected] of [
|
||||
[502, "LM Studio model load failed (502): upstream rejected ***; proxy ***"],
|
||||
[200, "LM Studio model load returned unexpected status: upstream rejected ***; proxy ***"],
|
||||
] as const) {
|
||||
responseStatus = status;
|
||||
await expect(
|
||||
ensureLmstudioModelLoaded({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
modelKey: "qwen3-8b-instruct",
|
||||
apiKey: "sk-test",
|
||||
headers: { "X-Proxy-Auth": "opaque-short" },
|
||||
}),
|
||||
).rejects.toThrow(expected);
|
||||
}
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -895,8 +895,9 @@ describe("lmstudio-models", () => {
|
||||
expect(bytesEmitted).toBeLessThan(32 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("bounds model load error bodies", async () => {
|
||||
const body = `${"lmstudio load unavailable ".repeat(512)}tail`;
|
||||
it("suppresses truncated model load error bodies", async () => {
|
||||
const credential = "split-credential-xyz";
|
||||
const body = `${"x".repeat(8 * 1024 - 2)}${credential}${"y".repeat(1000)}`;
|
||||
const tracked = cancelTrackedResponse(body, { status: 503 });
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||
@@ -914,17 +915,106 @@ describe("lmstudio-models", () => {
|
||||
|
||||
const error = await ensureLmstudioModelLoaded({
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: credential,
|
||||
modelKey: "qwen3-8b-instruct",
|
||||
}).catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toMatch(
|
||||
/LM Studio model load failed \(503\): lmstudio load unavailable/,
|
||||
);
|
||||
expect((error as Error).message).not.toContain("tail");
|
||||
expect((error as Error).message).toBe("LM Studio model load failed (503)");
|
||||
expect((error as Error).message).not.toContain(credential);
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each<{
|
||||
name: string;
|
||||
params: Pick<Parameters<typeof ensureLmstudioModelLoaded>[0], "apiKey" | "headers">;
|
||||
body: string;
|
||||
status: number;
|
||||
expected: string;
|
||||
}>([
|
||||
{
|
||||
name: "redacts trimmed short API keys without losing safe diagnostics",
|
||||
params: { apiKey: " sk-test " },
|
||||
body: "upstream rejected Bearer sk-test; GPU out of memory",
|
||||
status: 502,
|
||||
expected: "LM Studio model load failed (502): upstream rejected ***; GPU out of memory",
|
||||
},
|
||||
{
|
||||
name: "redacts bare credentials from lowercase header-only bearer auth",
|
||||
params: { headers: { authorization: "bearer opaque-short" } },
|
||||
body: "upstream rejected opaque-short",
|
||||
status: 502,
|
||||
expected: "LM Studio model load failed (502): upstream rejected ***",
|
||||
},
|
||||
{
|
||||
name: "redacts opaque custom authentication header values",
|
||||
params: { headers: { "X-Proxy-Auth": "proxy-p7" } },
|
||||
body: "proxy rejected proxy-p7",
|
||||
status: 502,
|
||||
expected: "LM Studio model load failed (502): proxy rejected ***",
|
||||
},
|
||||
{
|
||||
name: "redacts overlapping credentials longest first",
|
||||
params: { apiKey: "abc", headers: { "X-Proxy-Auth": "abcdef" } },
|
||||
body: "proxy rejected abcdef and abc",
|
||||
status: 502,
|
||||
expected: "LM Studio model load failed (502): proxy rejected *** and ***",
|
||||
},
|
||||
{
|
||||
name: "redacts only the authorization value actually sent",
|
||||
params: { apiKey: "fresh", headers: { Authorization: "Bearer replaced-old" } },
|
||||
body: "stale replaced-old; active fresh",
|
||||
status: 502,
|
||||
expected: "LM Studio model load failed (502): stale replaced-old; active ***",
|
||||
},
|
||||
{
|
||||
name: "preserves synthetic markers and the generated content type",
|
||||
params: { apiKey: "lmstudio-local" },
|
||||
body: "lmstudio-local rejected application/json; GPU out of memory",
|
||||
status: 502,
|
||||
expected:
|
||||
"LM Studio model load failed (502): lmstudio-local rejected application/json; GPU out of memory",
|
||||
},
|
||||
{
|
||||
name: "redacts unrelated recognizable provider credentials",
|
||||
params: {},
|
||||
body: "upstream Authorization: Bearer sk-test",
|
||||
status: 502,
|
||||
expected: "LM Studio model load failed (502): upstream Authorization: Bearer ***",
|
||||
},
|
||||
{
|
||||
name: "redacts reflected credentials in successful unexpected statuses",
|
||||
params: { headers: { authorization: "bearer opaque-short" } },
|
||||
body: "backend rejected opaque-short",
|
||||
status: 200,
|
||||
expected: "LM Studio model load returned unexpected status: backend rejected ***",
|
||||
},
|
||||
])("$name", async ({ params, body, status, expected }) => {
|
||||
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||
if (String(url).endsWith("/api/v1/models")) {
|
||||
return jsonResponse({
|
||||
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
|
||||
});
|
||||
}
|
||||
if (String(url).endsWith("/api/v1/models/load")) {
|
||||
return status === 200 ? jsonResponse({ status: body }) : new Response(body, { status });
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${String(url)}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", asFetch(fetchMock));
|
||||
|
||||
const error = await ensureLmstudioModelLoaded({
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
modelKey: "qwen3-8b-instruct",
|
||||
...params,
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
message: expected,
|
||||
resolvedModelKey: "qwen3-8b-instruct",
|
||||
});
|
||||
});
|
||||
|
||||
it("loads model with clamped context length and merged headers", async () => {
|
||||
const fetchMock = createModelLoadFetchMock({ maxContextLength: 32768 });
|
||||
vi.stubGlobal("fetch", asFetch(fetchMock));
|
||||
|
||||
Reference in New Issue
Block a user