Skip to content

Commit 92e5a10

Browse files
authored
fix(doctor): diagnose ignored web fetch proxy settings (#109778)
* fix(doctor): diagnose direct web fetch proxy routing * test(doctor): cover proxy diagnostic through public API * test(doctor): validate emitted proxy diagnostic type
1 parent a9ac13b commit 92e5a10

5 files changed

Lines changed: 283 additions & 0 deletions

File tree

docs/cli/doctor.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ compare restored legacy artifacts with the SQLite rows before importing.
383383
- Doctor reports cron jobs still marked in-flight (`state.runningAtMs`), which can make `openclaw cron list` show them as `running`. This check is read-only: if no Gateway is currently executing a marked job, the next cron service startup records the interrupted run and clears the marker.
384384
- On Linux, doctor warns when the user's crontab still runs the unmaintained legacy `~/.openclaw/bin/ensure-whatsapp.sh`, which can misreport `Gateway inactive` when cron lacks the systemd user-bus environment.
385385
- When WhatsApp is enabled, doctor checks for a degraded Gateway event loop with local `openclaw-tui` clients still running. `doctor --fix` stops only verified local TUI clients so WhatsApp replies are not queued behind stale TUI refresh loops.
386+
- When HTTP(S) proxy environment variables are present but `tools.web.fetch.useTrustedEnvProxy` is disabled, doctor explains that `web_fetch` still uses direct routing, runs a short direct TLS connectivity probe, and names the explicit opt-in. It never enables proxy trust automatically.
386387
- Doctor rewrites legacy `codex/*` and `openai-codex/*` model refs to canonical `openai/*` refs across primary models, fallbacks, model allowlists, image/video generation models, heartbeat/subagent/compaction overrides, hooks, channel model overrides, cron payloads, and stale session/transcript route pins. `--fix` also merges legacy `models.providers.codex` and `models.providers.openai-codex` config when safe, migrates legacy `openai-codex:*` auth profiles and `auth.order.openai-codex` entries to `openai:*`, moves Codex intent onto provider/model-scoped `agentRuntime.id: "codex"` entries, removes stale whole-agent/session runtime pins, and keeps repaired OpenAI agent refs on Codex auth routing instead of direct OpenAI API-key auth.
387388
- Doctor reports nonempty `auth.order.<provider>` lists whose referenced profiles are all gone while compatible stored credentials exist. `doctor --fix` deletes only those stale overrides, restoring automatic per-agent credential selection; explicit empty orders, partially live lists, and orders without a compatible stored credential stay unchanged. If an active SQLite auth store is unreadable or malformed, doctor explains why it skipped this repair. Restart a running Gateway before rechecking auth status if its config reload mode does not apply the write automatically.
388389
- Doctor cleans legacy plugin dependency staging state from older OpenClaw versions and relinks the host `openclaw` package for managed npm plugins that declare it as a peer dependency. It also repairs missing downloadable plugins referenced by config (`plugins.entries`, configured channels, configured provider/search settings, configured agent runtimes). During package updates, doctor skips package-manager plugin repair until the package swap completes; rerun `openclaw doctor --fix` afterward if a configured plugin still needs recovery. If a download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Doctor web fetch proxy tests cover explicit opt-in diagnostics without exposing proxy values.
2+
import { describe, expect, it, vi } from "vitest";
3+
import type { OpenClawConfig } from "../config/types.openclaw.js";
4+
import { noteWebFetchProxyDiagnostic } from "./doctor-web-fetch-proxy.js";
5+
6+
function serviceWithEnv(environment?: Record<string, string>) {
7+
return {
8+
readCommand: vi.fn(async () =>
9+
environment ? { programArguments: ["openclaw", "gateway"], environment } : null,
10+
),
11+
};
12+
}
13+
14+
async function collectDiagnostic(
15+
params: Omit<Parameters<typeof noteWebFetchProxyDiagnostic>[0], "noteFn">,
16+
): Promise<string | null> {
17+
let diagnostic: string | null = null;
18+
await noteWebFetchProxyDiagnostic({
19+
...params,
20+
noteFn: (message) => {
21+
if (typeof message !== "string") {
22+
throw new TypeError("expected doctor proxy diagnostic to be a string");
23+
}
24+
diagnostic = message;
25+
},
26+
});
27+
return diagnostic;
28+
}
29+
30+
describe("web_fetch proxy doctor diagnostic", () => {
31+
it("reports direct routing for an installed Gateway proxy without exposing its value", async () => {
32+
const proxyUrl = "http://private-proxy.example:8080/proxy-value-marker";
33+
const diagnostic = await collectDiagnostic({
34+
cfg: {},
35+
env: {},
36+
service: serviceWithEnv({ HTTPS_PROXY: proxyUrl }),
37+
probeDirectConnectivity: vi.fn(async () => "unreachable" as const),
38+
});
39+
40+
expect(diagnostic).toContain(
41+
"HTTP(S) proxy environment detected in the installed Gateway service: HTTPS_PROXY",
42+
);
43+
expect(diagnostic).toContain("web_fetch still uses direct connections");
44+
expect(diagnostic).toContain("tools.web.fetch.useTrustedEnvProxy is not enabled");
45+
expect(diagnostic).toContain("Direct TLS connectivity to docs.openclaw.ai:443 failed");
46+
expect(diagnostic).toContain("openclaw config set tools.web.fetch.useTrustedEnvProxy true");
47+
expect(diagnostic).not.toContain(proxyUrl);
48+
expect(diagnostic).not.toContain("proxy-value-marker");
49+
});
50+
51+
it("reports a reachable direct path from the doctor process", async () => {
52+
const diagnostic = await collectDiagnostic({
53+
cfg: {},
54+
env: { http_proxy: "http://proxy.example:8080" },
55+
service: serviceWithEnv(),
56+
probeDirectConnectivity: vi.fn(async () => "reachable" as const),
57+
});
58+
59+
expect(diagnostic).toContain("proxy environment detected in the doctor process: http_proxy");
60+
expect(diagnostic).toContain("Direct TLS connectivity to docs.openclaw.ai:443 succeeded");
61+
});
62+
63+
it("reports both process and installed service proxy sources", async () => {
64+
const diagnostic = await collectDiagnostic({
65+
cfg: {},
66+
env: { HTTP_PROXY: "http://shell-proxy.example:8080" },
67+
service: serviceWithEnv({ HTTPS_PROXY: "http://service-proxy.example:8080" }),
68+
probeDirectConnectivity: vi.fn(async () => "reachable" as const),
69+
});
70+
71+
expect(diagnostic).toContain("doctor process: HTTP_PROXY");
72+
expect(diagnostic).toContain("installed Gateway service: HTTPS_PROXY");
73+
});
74+
75+
it("does nothing when no HTTP(S) proxy is effective", async () => {
76+
const probe = vi.fn(async () => "reachable" as const);
77+
78+
await expect(
79+
collectDiagnostic({
80+
cfg: {},
81+
env: { ALL_PROXY: "socks5://proxy.example:1080" },
82+
service: serviceWithEnv(),
83+
probeDirectConnectivity: probe,
84+
}),
85+
).resolves.toBeNull();
86+
expect(probe).not.toHaveBeenCalled();
87+
});
88+
89+
it.each([
90+
{
91+
name: "trusted proxy opt-in is enabled",
92+
cfg: { tools: { web: { fetch: { useTrustedEnvProxy: true } } } },
93+
},
94+
{
95+
name: "web_fetch is disabled",
96+
cfg: { tools: { web: { fetch: { enabled: false } } } },
97+
},
98+
{
99+
name: "Gateway mode is remote",
100+
cfg: { gateway: { mode: "remote" } },
101+
},
102+
])("does nothing when $name", async ({ cfg }) => {
103+
const service = serviceWithEnv({ HTTPS_PROXY: "http://proxy.example:8080" });
104+
const probe = vi.fn(async () => "unreachable" as const);
105+
106+
await expect(
107+
collectDiagnostic({
108+
cfg: cfg as OpenClawConfig,
109+
env: {},
110+
service,
111+
probeDirectConnectivity: probe,
112+
}),
113+
).resolves.toBeNull();
114+
expect(service.readCommand).not.toHaveBeenCalled();
115+
expect(probe).not.toHaveBeenCalled();
116+
});
117+
118+
it("emits one titled note", async () => {
119+
const noteFn = vi.fn();
120+
121+
await noteWebFetchProxyDiagnostic({
122+
cfg: {},
123+
env: { HTTPS_PROXY: "http://proxy.example:8080" },
124+
service: serviceWithEnv(),
125+
probeDirectConnectivity: vi.fn(async () => "reachable" as const),
126+
noteFn,
127+
});
128+
129+
expect(noteFn).toHaveBeenCalledTimes(1);
130+
expect(noteFn).toHaveBeenCalledWith(expect.stringContaining("web_fetch"), "Web fetch proxy");
131+
});
132+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/** Doctor diagnostics for explicit web_fetch trusted proxy routing. */
2+
import tls from "node:tls";
3+
import { note } from "../../packages/terminal-core/src/note.js";
4+
import { formatCliCommand } from "../cli/command-format.js";
5+
import type { OpenClawConfig } from "../config/types.openclaw.js";
6+
import { resolveGatewayService, type GatewayService } from "../daemon/service.js";
7+
import { hasEnvHttpProxyConfigured } from "../infra/net/proxy-env.js";
8+
9+
const DIRECT_PROBE_HOST = "docs.openclaw.ai";
10+
const DIRECT_PROBE_PORT = 443;
11+
const DIRECT_PROBE_TIMEOUT_MS = 3_000;
12+
const HTTP_PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] as const;
13+
14+
type DirectConnectivity = "reachable" | "unreachable";
15+
16+
type ProxyEnvSource = {
17+
env: NodeJS.ProcessEnv;
18+
label: "doctor process" | "installed Gateway service";
19+
};
20+
21+
function listConfiguredProxyKeys(env: NodeJS.ProcessEnv): string[] {
22+
return HTTP_PROXY_ENV_KEYS.filter((key) => Boolean(env[key]?.trim()));
23+
}
24+
25+
async function probeDirectTlsConnectivity(): Promise<DirectConnectivity> {
26+
return await new Promise((resolve) => {
27+
let settled = false;
28+
const socket = tls.connect({
29+
host: DIRECT_PROBE_HOST,
30+
port: DIRECT_PROBE_PORT,
31+
servername: DIRECT_PROBE_HOST,
32+
timeout: DIRECT_PROBE_TIMEOUT_MS,
33+
});
34+
const finish = (result: DirectConnectivity) => {
35+
if (settled) {
36+
return;
37+
}
38+
settled = true;
39+
socket.destroy();
40+
resolve(result);
41+
};
42+
socket.once("secureConnect", () => finish("reachable"));
43+
socket.once("timeout", () => finish("unreachable"));
44+
socket.once("error", () => finish("unreachable"));
45+
});
46+
}
47+
48+
async function resolveProxyEnvSources(params: {
49+
env: NodeJS.ProcessEnv;
50+
service: Pick<GatewayService, "readCommand">;
51+
}): Promise<ProxyEnvSource[]> {
52+
const sources: ProxyEnvSource[] = [];
53+
if (hasEnvHttpProxyConfigured("https", params.env)) {
54+
sources.push({ env: params.env, label: "doctor process" });
55+
}
56+
const command = await params.service.readCommand(params.env).catch(() => null);
57+
const serviceEnv = command?.environment;
58+
if (serviceEnv && hasEnvHttpProxyConfigured("https", serviceEnv)) {
59+
sources.push({ env: serviceEnv, label: "installed Gateway service" });
60+
}
61+
return sources;
62+
}
63+
64+
/** Builds a read-only diagnostic when proxy env exists but web_fetch remains direct. */
65+
async function collectWebFetchProxyDiagnostic(params: {
66+
cfg: OpenClawConfig;
67+
env?: NodeJS.ProcessEnv;
68+
service?: Pick<GatewayService, "readCommand">;
69+
probeDirectConnectivity?: () => Promise<DirectConnectivity>;
70+
}): Promise<string | null> {
71+
if (
72+
params.cfg.gateway?.mode === "remote" ||
73+
params.cfg.tools?.web?.fetch?.enabled === false ||
74+
params.cfg.tools?.web?.fetch?.useTrustedEnvProxy === true
75+
) {
76+
return null;
77+
}
78+
79+
const env = params.env ?? process.env;
80+
const sources = await resolveProxyEnvSources({
81+
env,
82+
service: params.service ?? resolveGatewayService(),
83+
});
84+
if (sources.length === 0) {
85+
return null;
86+
}
87+
88+
const directConnectivity = await (params.probeDirectConnectivity ?? probeDirectTlsConnectivity)();
89+
const sourceLines = sources.map((source) => {
90+
const keys = listConfiguredProxyKeys(source.env);
91+
return `- HTTP(S) proxy environment detected in the ${source.label}: ${keys.join(", ")}.`;
92+
});
93+
const directProbe =
94+
directConnectivity === "reachable"
95+
? `- Direct TLS connectivity to ${DIRECT_PROBE_HOST}:${DIRECT_PROBE_PORT} succeeded.`
96+
: `- Direct TLS connectivity to ${DIRECT_PROBE_HOST}:${DIRECT_PROBE_PORT} failed.`;
97+
98+
return [
99+
...sourceLines,
100+
"- web_fetch still uses direct connections because tools.web.fetch.useTrustedEnvProxy is not enabled.",
101+
directProbe,
102+
"- If direct web_fetch requests time out and the proxy is operator-controlled, enable the explicit opt-in:",
103+
` ${formatCliCommand("openclaw config set tools.web.fetch.useTrustedEnvProxy true")}`,
104+
"- Keep the opt-in disabled for untrusted proxies; enabling it lets the proxy resolve DNS after OpenClaw's hostname checks.",
105+
].join("\n");
106+
}
107+
108+
/** Emits the web_fetch proxy diagnostic when relevant. */
109+
export async function noteWebFetchProxyDiagnostic(params: {
110+
cfg: OpenClawConfig;
111+
env?: NodeJS.ProcessEnv;
112+
service?: Pick<GatewayService, "readCommand">;
113+
probeDirectConnectivity?: () => Promise<DirectConnectivity>;
114+
noteFn?: typeof note;
115+
}): Promise<void> {
116+
const diagnostic = await collectWebFetchProxyDiagnostic(params);
117+
if (diagnostic) {
118+
(params.noteFn ?? note)(diagnostic, "Web fetch proxy");
119+
}
120+
}

src/flows/doctor-health-contributions.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({
2828
noteAuthProfileHealth: vi.fn().mockResolvedValue(undefined),
2929
noteLegacyCodexProviderOverride: vi.fn(),
3030
noteMemorySearchHealth: vi.fn().mockResolvedValue(undefined),
31+
noteWebFetchProxyDiagnostic: vi.fn().mockResolvedValue(undefined),
3132
buildGatewayConnectionDetails: vi.fn(() => ({ message: "gateway details" })),
3233
callGateway: vi.fn(),
3334
resolveSecretInputRef: vi.fn((params: { value?: unknown }) => ({
@@ -254,6 +255,10 @@ vi.mock("../commands/doctor-memory-search.js", () => ({
254255
noteMemorySearchHealth: mocks.noteMemorySearchHealth,
255256
}));
256257

258+
vi.mock("../commands/doctor-web-fetch-proxy.js", () => ({
259+
noteWebFetchProxyDiagnostic: mocks.noteWebFetchProxyDiagnostic,
260+
}));
261+
257262
vi.mock("../gateway/call.js", () => ({
258263
buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails,
259264
callGateway: mocks.callGateway,
@@ -532,6 +537,8 @@ describe("doctor health contributions", () => {
532537
mocks.noteLegacyCodexProviderOverride.mockClear();
533538
mocks.noteMemorySearchHealth.mockClear();
534539
mocks.noteMemorySearchHealth.mockResolvedValue(undefined);
540+
mocks.noteWebFetchProxyDiagnostic.mockClear();
541+
mocks.noteWebFetchProxyDiagnostic.mockResolvedValue(undefined);
535542
mocks.buildGatewayConnectionDetails.mockClear();
536543
mocks.buildGatewayConnectionDetails.mockReturnValue({ message: "gateway details" });
537544
mocks.callGateway.mockReset();
@@ -968,6 +975,19 @@ describe("doctor health contributions", () => {
968975
expect(ids.indexOf("doctor:command-owner")).toBeLessThan(ids.indexOf("doctor:write-config"));
969976
});
970977

978+
it("runs the web fetch proxy diagnostic after security checks", async () => {
979+
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
980+
const contribution = requireDoctorContribution("doctor:web-fetch-proxy");
981+
const cfg = { gateway: { mode: "local" as const } };
982+
const env = { HTTPS_PROXY: "http://proxy.example:8080" };
983+
const ctx = { cfg, env } as unknown as Parameters<(typeof contribution)["run"]>[0];
984+
985+
expect(ids.indexOf("doctor:security")).toBeLessThan(ids.indexOf("doctor:web-fetch-proxy"));
986+
await contribution.run(ctx);
987+
988+
expect(mocks.noteWebFetchProxyDiagnostic).toHaveBeenCalledWith({ cfg, env });
989+
});
990+
971991
it("checks skill readiness before final config writes", () => {
972992
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
973993

src/flows/doctor-health-contributions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,11 @@ async function runSecurityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
757757
await noteInstallPolicyHealth(ctx.cfg, { deep: ctx.options.deep === true, env: ctx.env });
758758
}
759759

760+
async function runWebFetchProxyHealth(ctx: DoctorHealthFlowContext): Promise<void> {
761+
const { noteWebFetchProxyDiagnostic } = await import("../commands/doctor-web-fetch-proxy.js");
762+
await noteWebFetchProxyDiagnostic({ cfg: ctx.cfg, env: ctx.env ?? process.env });
763+
}
764+
760765
async function runBrowserHealth(ctx: DoctorHealthFlowContext): Promise<void> {
761766
const { noteChromeMcpBrowserReadiness } = await import("../commands/doctor-browser.js");
762767
await runCoreContributionHealthRepair(ctx, ["core/doctor/browser-clawd-profile-residue"]);
@@ -2032,6 +2037,11 @@ function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
20322037
healthCheckIds: ["core/doctor/security"],
20332038
run: runSecurityHealth,
20342039
}),
2040+
createDoctorHealthContribution({
2041+
id: "doctor:web-fetch-proxy",
2042+
label: "Web fetch proxy",
2043+
run: runWebFetchProxyHealth,
2044+
}),
20352045
createDoctorHealthContribution({
20362046
id: "doctor:browser",
20372047
label: "Browser",

0 commit comments

Comments
 (0)