Skip to content

Commit 71617ef

Browse files
qasobviyus
andauthored
fix: allow private network provider request opt-in (openclaw#63671)
* feat(models): allow private network via models.providers.*.request Add optional request.allowPrivateNetwork for operator-controlled self-hosted OpenAI-compatible bases (LAN/overlay/split DNS). Plumbs the flag into resolveProviderRequestPolicyConfig for streaming provider HTTP and OpenAI responses WebSocket so SSRF policy can allow private-resolved model URLs when explicitly enabled. Updates zod schema, config help/labels, and unit tests for sanitize/merge. * agents thread provider request into websocket stream * fix(config): scope allowPrivateNetwork to model requests * fix(agents): refresh websocket manager on request changes * fix(agents): scope runtime private-network overrides to models * fix: allow private network provider request opt-in (openclaw#63671) (thanks @qas) --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
1 parent c61be87 commit 71617ef

17 files changed

Lines changed: 258 additions & 25 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111
- Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819.
1212
- QA/testing: add a `--runner multipass` lane for `openclaw qa suite` so repo-backed QA scenarios can run inside a disposable Linux VM and write back the usual report, summary, and VM logs. (#63426) Thanks @shakkernerd.
1313
- Gateway: split startup and runtime seams so gateway lifecycle sequencing, reload state, and shutdown behavior stay easier to maintain without changing observed behavior. (#63975) Thanks @gumadeiras.
14+
- Models/providers: add per-provider `models.providers.*.request.allowPrivateNetwork` for trusted self-hosted OpenAI-compatible endpoints, keep the opt-in scoped to model request surfaces, and refresh cached WebSocket managers when request transport overrides change. (#63671) Thanks @qas.
1415

1516
### Fixes
1617

docs/gateway/configuration-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2402,6 +2402,7 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
24022402
- `request.auth`: auth strategy override. Modes: `"provider-default"` (use provider's built-in auth), `"authorization-bearer"` (with `token`), `"header"` (with `headerName`, `value`, optional `prefix`).
24032403
- `request.proxy`: HTTP proxy override. Modes: `"env-proxy"` (use `HTTP_PROXY`/`HTTPS_PROXY` env vars), `"explicit-proxy"` (with `url`). Both modes accept an optional `tls` sub-object.
24042404
- `request.tls`: TLS override for direct connections. Fields: `ca`, `cert`, `key`, `passphrase` (all accept SecretRef), `serverName`, `insecureSkipVerify`.
2405+
- `request.allowPrivateNetwork`: when `true`, allow HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`.
24052406
- `models.providers.*.models`: explicit provider model catalog entries.
24062407
- `models.providers.*.models.*.contextWindow`: native model context window metadata.
24072408
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this when you want a smaller effective context budget than the model's native `contextWindow`.

src/agents/openai-ws-connection.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { buildOpenAIWebSocketWarmUpPayload } from "./openai-ws-request.js";
1919
import {
2020
buildProviderRequestTlsClientOptions,
2121
resolveProviderRequestPolicyConfig,
22-
type ProviderRequestTransportOverrides,
22+
type ModelProviderRequestTransportOverrides,
2323
} from "./provider-request-config.js";
2424

2525
// ─────────────────────────────────────────────────────────────────────────────
@@ -289,7 +289,7 @@ export interface OpenAIWebSocketManagerOptions {
289289
/** Extra headers merged into the initial WebSocket handshake request. */
290290
headers?: Record<string, string>;
291291
/** Optional transport overrides for provider-owned auth or TLS wiring. */
292-
request?: ProviderRequestTransportOverrides;
292+
request?: ModelProviderRequestTransportOverrides;
293293
}
294294

295295
export type OpenAIWebSocketConnectionState =
@@ -346,7 +346,7 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
346346
private readonly backoffDelaysMs: readonly number[];
347347
private readonly socketFactory: (url: string, options: ClientOptions) => WebSocket;
348348
private readonly headers?: Record<string, string>;
349-
private readonly request?: ProviderRequestTransportOverrides;
349+
private readonly request?: ModelProviderRequestTransportOverrides;
350350

351351
constructor(options: OpenAIWebSocketManagerOptions = {}) {
352352
super();
@@ -467,6 +467,7 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
467467
},
468468
precedence: "defaults-win",
469469
request: this.request,
470+
allowPrivateNetwork: this.request?.allowPrivateNetwork === true,
470471
});
471472
const socket = this.socketFactory(this.wsUrl, {
472473
headers: requestConfig.headers,

src/agents/openai-ws-stream.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3428,6 +3428,87 @@ describe("releaseWsSession / hasWsSession", () => {
34283428
it("releaseWsSession is a no-op for unknown sessions", () => {
34293429
expect(() => releaseWsSession("nonexistent-session")).not.toThrow();
34303430
});
3431+
3432+
it("recreates the cached manager when request overrides change for the same session", async () => {
3433+
const sessionId = "registry-test";
3434+
const firstStreamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId, {
3435+
managerOptions: {
3436+
request: {
3437+
headers: { "x-test": "one" },
3438+
},
3439+
},
3440+
});
3441+
const firstStream = firstStreamFn(
3442+
{
3443+
api: "openai-responses",
3444+
provider: "openai",
3445+
id: "gpt-5.4",
3446+
contextWindow: 128000,
3447+
maxTokens: 4096,
3448+
reasoning: false,
3449+
input: ["text"],
3450+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3451+
name: "GPT-5.4",
3452+
} as Parameters<typeof firstStreamFn>[0],
3453+
{
3454+
systemPrompt: "test",
3455+
messages: [userMsg("Hi") as Parameters<typeof convertMessagesToInputItems>[0][number]],
3456+
tools: [],
3457+
} as Parameters<typeof firstStreamFn>[1],
3458+
);
3459+
3460+
await new Promise((r) => setImmediate(r));
3461+
const firstManager = MockManager.lastInstance!;
3462+
firstManager.simulateEvent({
3463+
type: "response.completed",
3464+
response: makeResponseObject("resp-first", "done"),
3465+
});
3466+
for await (const _ of await resolveStream(firstStream)) {
3467+
// consume
3468+
}
3469+
3470+
const secondStreamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId, {
3471+
managerOptions: {
3472+
request: {
3473+
headers: { "x-test": "two" },
3474+
allowPrivateNetwork: true,
3475+
},
3476+
},
3477+
});
3478+
const secondStream = secondStreamFn(
3479+
{
3480+
api: "openai-responses",
3481+
provider: "openai",
3482+
id: "gpt-5.4",
3483+
contextWindow: 128000,
3484+
maxTokens: 4096,
3485+
reasoning: false,
3486+
input: ["text"],
3487+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3488+
name: "GPT-5.4",
3489+
} as Parameters<typeof secondStreamFn>[0],
3490+
{
3491+
systemPrompt: "test",
3492+
messages: [userMsg("Again") as Parameters<typeof convertMessagesToInputItems>[0][number]],
3493+
tools: [],
3494+
} as Parameters<typeof secondStreamFn>[1],
3495+
);
3496+
3497+
await new Promise((r) => setImmediate(r));
3498+
expect(MockManager.instances).toHaveLength(2);
3499+
expect(firstManager.closeCallCount).toBe(1);
3500+
const secondManager = MockManager.lastInstance!;
3501+
expect(secondManager).not.toBe(firstManager);
3502+
expect(secondManager.connectCallCount).toBe(1);
3503+
3504+
secondManager.simulateEvent({
3505+
type: "response.completed",
3506+
response: makeResponseObject("resp-second", "done"),
3507+
});
3508+
for await (const _ of await resolveStream(secondStream)) {
3509+
// consume
3510+
}
3511+
});
34313512
});
34323513

34333514
describe("convertMessagesToInputItems — phase inheritance", () => {

src/agents/openai-ws-stream.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import { mergeTransportMetadata } from "./transport-stream-shared.js";
7171

7272
interface WsSession {
7373
manager: OpenAIWebSocketManager;
74+
managerConfigSignature: string;
7475
/** Number of messages that were in context.messages at the END of the last streamFn call. */
7576
lastContextLength: number;
7677
/** True if the connection has been established at least once. */
@@ -336,6 +337,30 @@ function createWsManager(
336337
});
337338
}
338339

340+
function stringifyStable(value: unknown): string {
341+
if (value === null || typeof value !== "object") {
342+
return JSON.stringify(value);
343+
}
344+
if (Array.isArray(value)) {
345+
return `[${value.map((entry) => stringifyStable(entry)).join(",")}]`;
346+
}
347+
const entries = Object.entries(value).toSorted(([left], [right]) => left.localeCompare(right));
348+
return `{${entries
349+
.map(([key, entry]) => `${JSON.stringify(key)}:${stringifyStable(entry)}`)
350+
.join(",")}}`;
351+
}
352+
353+
function resolveWsManagerConfigSignature(
354+
managerOptions: OpenAIWebSocketManagerOptions | undefined,
355+
sessionHeaders?: Record<string, string>,
356+
): string {
357+
return stringifyStable({
358+
headers: sessionHeaders,
359+
request: managerOptions?.request,
360+
url: managerOptions?.url,
361+
});
362+
}
363+
339364
const AZURE_OPENAI_PROVIDER_IDS = new Set(["azure-openai", "azure-openai-responses"]);
340365
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
341366

@@ -661,10 +686,15 @@ export function createOpenAIWebSocketStreamFn(
661686

662687
while (true) {
663688
let session = wsRegistry.get(sessionId);
689+
const managerConfigSignature = resolveWsManagerConfigSignature(
690+
opts.managerOptions,
691+
sessionHeaders,
692+
);
664693
if (!session) {
665694
const manager = createWsManager(opts.managerOptions, sessionHeaders);
666695
session = {
667696
manager,
697+
managerConfigSignature,
668698
lastContextLength: 0,
669699
everConnected: false,
670700
warmUpAttempted: false,
@@ -673,6 +703,13 @@ export function createOpenAIWebSocketStreamFn(
673703
degradeCooldownMs: wsSessionPolicy.degradeCooldownMs,
674704
};
675705
wsRegistry.set(sessionId, session);
706+
} else if (session.managerConfigSignature !== managerConfigSignature) {
707+
resetWsSession({
708+
session,
709+
createManager: () => createWsManager(opts.managerOptions, sessionHeaders),
710+
});
711+
session.managerConfigSignature = managerConfigSignature;
712+
session.degradeCooldownMs = wsSessionPolicy.degradeCooldownMs;
676713
}
677714

678715
if (transport !== "websocket" && isWsSessionDegraded(session)) {

src/agents/pi-embedded-runner/stream-resolution.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core";
22
import { streamSimple } from "@mariozechner/pi-ai";
33
import { createAnthropicVertexStreamFnForModel } from "../anthropic-vertex-stream.js";
44
import { createOpenAIWebSocketStreamFn } from "../openai-ws-stream.js";
5+
import { getModelProviderRequestTransport } from "../provider-request-config.js";
56
import { createBoundaryAwareStreamFnForModel } from "../provider-transport-stream.js";
67
import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js";
78
import type { EmbeddedRunAttemptParams } from "./run/types.js";
@@ -105,6 +106,9 @@ export function resolveEmbeddedAgentStreamFn(params: {
105106
return params.wsApiKey
106107
? createOpenAIWebSocketStreamFn(params.wsApiKey, params.sessionId, {
107108
signal: params.signal,
109+
managerOptions: {
110+
request: getModelProviderRequestTransport(params.model),
111+
},
108112
})
109113
: currentStreamFn;
110114
}

src/agents/provider-request-config.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
buildProviderRequestDispatcherPolicy,
4+
mergeModelProviderRequestOverrides,
45
mergeProviderRequestOverrides,
56
resolveProviderRequestPolicyConfig,
67
resolveProviderRequestConfig,
@@ -9,6 +10,7 @@ import {
910
sanitizeConfiguredProviderRequest,
1011
sanitizeRuntimeProviderRequestOverrides,
1112
} from "./provider-request-config.js";
13+
import type { ProviderRequestTransportOverrides } from "./provider-request-config.js";
1214

1315
describe("provider request config", () => {
1416
it("merges discovered, provider, and model headers in precedence order", () => {
@@ -332,6 +334,35 @@ describe("provider request config", () => {
332334
});
333335
});
334336

337+
it("preserves request.allowPrivateNetwork for operator-trusted LAN/overlay model bases", () => {
338+
expect(sanitizeConfiguredModelProviderRequest({ allowPrivateNetwork: true })).toEqual({
339+
allowPrivateNetwork: true,
340+
});
341+
expect(sanitizeConfiguredModelProviderRequest({ allowPrivateNetwork: false })).toEqual({
342+
allowPrivateNetwork: false,
343+
});
344+
expect(
345+
sanitizeConfiguredProviderRequest({
346+
allowPrivateNetwork: true,
347+
} as ProviderRequestTransportOverrides),
348+
).toBeUndefined();
349+
});
350+
351+
it("merges allowPrivateNetwork with later override winning", () => {
352+
expect(
353+
mergeModelProviderRequestOverrides(
354+
{ allowPrivateNetwork: true },
355+
{ allowPrivateNetwork: false },
356+
),
357+
).toEqual({ allowPrivateNetwork: false });
358+
expect(
359+
mergeModelProviderRequestOverrides(
360+
{ allowPrivateNetwork: false },
361+
{ allowPrivateNetwork: true },
362+
),
363+
).toEqual({ allowPrivateNetwork: true });
364+
});
365+
335366
it("merges configured request overrides with later entries winning", () => {
336367
expect(
337368
mergeProviderRequestOverrides(

src/agents/provider-request-config.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export type ProviderRequestTransportOverrides = {
5959
tls?: ProviderRequestTlsOverride;
6060
};
6161

62+
export type ModelProviderRequestTransportOverrides = ProviderRequestTransportOverrides & {
63+
allowPrivateNetwork?: boolean;
64+
};
65+
6266
export type ResolvedProviderRequestAuthConfig =
6367
| {
6468
configured: false;
@@ -158,7 +162,7 @@ type ResolveProviderRequestPolicyConfigParams = {
158162
} | null;
159163
modelId?: string | null;
160164
allowPrivateNetwork?: boolean;
161-
request?: ProviderRequestTransportOverrides;
165+
request?: ModelProviderRequestTransportOverrides;
162166
};
163167

164168
function sanitizeConfiguredRequestString(value: unknown, path: string): string | undefined {
@@ -173,7 +177,7 @@ function sanitizeConfiguredRequestString(value: unknown, path: string): string |
173177
}
174178

175179
export function sanitizeConfiguredProviderRequest(
176-
request: ConfiguredModelProviderRequest | ProviderRequestTransportOverrides | undefined,
180+
request: ProviderRequestTransportOverrides | undefined,
177181
): ProviderRequestTransportOverrides | undefined {
178182
if (!request || typeof request !== "object" || Array.isArray(request)) {
179183
return undefined;
@@ -300,8 +304,17 @@ export function sanitizeConfiguredProviderRequest(
300304

301305
export function sanitizeConfiguredModelProviderRequest(
302306
request: ConfiguredModelProviderRequest | undefined,
303-
): ProviderRequestTransportOverrides | undefined {
304-
return sanitizeConfiguredProviderRequest(request);
307+
): ModelProviderRequestTransportOverrides | undefined {
308+
const sanitized = sanitizeConfiguredProviderRequest(request);
309+
const rawAllow = request?.allowPrivateNetwork;
310+
const allowPrivateNetwork = rawAllow === true ? true : rawAllow === false ? false : undefined;
311+
if (!sanitized && allowPrivateNetwork === undefined) {
312+
return undefined;
313+
}
314+
return {
315+
...sanitized,
316+
...(allowPrivateNetwork !== undefined ? { allowPrivateNetwork } : {}),
317+
};
305318
}
306319

307320
export function mergeProviderRequestOverrides(
@@ -325,11 +338,29 @@ export function mergeProviderRequestOverrides(
325338
...(current.auth ? { auth: current.auth } : {}),
326339
...(current.proxy ? { proxy: current.proxy } : {}),
327340
...(current.tls ? { tls: current.tls } : {}),
341+
...(current.allowPrivateNetwork !== undefined
342+
? { allowPrivateNetwork: current.allowPrivateNetwork }
343+
: {}),
328344
};
329345
}
330346
return merged;
331347
}
332348

349+
export function mergeModelProviderRequestOverrides(
350+
...overrides: Array<ModelProviderRequestTransportOverrides | undefined>
351+
): ModelProviderRequestTransportOverrides | undefined {
352+
let merged = mergeProviderRequestOverrides(...overrides);
353+
for (const current of overrides) {
354+
if (current?.allowPrivateNetwork !== undefined) {
355+
merged = {
356+
...merged,
357+
allowPrivateNetwork: current.allowPrivateNetwork,
358+
};
359+
}
360+
}
361+
return merged;
362+
}
363+
333364
export function normalizeBaseUrl(baseUrl: string | undefined, fallback: string): string;
334365
export function normalizeBaseUrl(
335366
baseUrl: string | undefined,
@@ -691,12 +722,12 @@ const MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL = Symbol.for(
691722
);
692723

693724
type ModelWithProviderRequestTransport = {
694-
[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL]?: ProviderRequestTransportOverrides;
725+
[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL]?: ModelProviderRequestTransportOverrides;
695726
};
696727

697728
export function attachModelProviderRequestTransport<TModel extends object>(
698729
model: TModel,
699-
request: ProviderRequestTransportOverrides | undefined,
730+
request: ModelProviderRequestTransportOverrides | undefined,
700731
): TModel {
701732
if (!request) {
702733
return model;
@@ -708,6 +739,6 @@ export function attachModelProviderRequestTransport<TModel extends object>(
708739

709740
export function getModelProviderRequestTransport(
710741
model: object,
711-
): ProviderRequestTransportOverrides | undefined {
742+
): ModelProviderRequestTransportOverrides | undefined {
712743
return (model as ModelWithProviderRequestTransport)[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL];
713744
}

src/agents/provider-transport-fetch.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,15 @@ function buildManagedResponse(response: Response, release: () => Promise<void>):
5555
}
5656

5757
function resolveModelRequestPolicy(model: Model<Api>) {
58+
const request = getModelProviderRequestTransport(model);
5859
return resolveProviderRequestPolicyConfig({
5960
provider: model.provider,
6061
api: model.api,
6162
baseUrl: model.baseUrl,
6263
capability: "llm",
6364
transport: "stream",
64-
request: getModelProviderRequestTransport(model),
65+
request,
66+
allowPrivateNetwork: request?.allowPrivateNetwork === true,
6567
});
6668
}
6769

0 commit comments

Comments
 (0)