@wangzn04/openclaw-gwcheck 2026.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.test.ts +41 -0
- package/index.ts +246 -0
- package/openclaw.plugin.json +8 -0
- package/package.json +20 -0
package/index.test.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { runGatewayConfigCheck } from "./index.js";
|
|
3
|
+
|
|
4
|
+
describe("gwcheck plugin", () => {
|
|
5
|
+
it("reports error when gateway.mode is missing", () => {
|
|
6
|
+
const report = runGatewayConfigCheck({});
|
|
7
|
+
expect(report.ok).toBe(false);
|
|
8
|
+
expect(report.findings.some((finding) => finding.code === "gateway_mode_missing")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("passes for local mode with token auth configured", () => {
|
|
12
|
+
const report = runGatewayConfigCheck({
|
|
13
|
+
gateway: {
|
|
14
|
+
mode: "local",
|
|
15
|
+
auth: {
|
|
16
|
+
mode: "token",
|
|
17
|
+
token: "test-token",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(report.ok).toBe(true);
|
|
23
|
+
expect(report.findings).toHaveLength(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("flags insecure remote ws url", () => {
|
|
27
|
+
const report = runGatewayConfigCheck({
|
|
28
|
+
gateway: {
|
|
29
|
+
mode: "remote",
|
|
30
|
+
remote: {
|
|
31
|
+
url: "ws://example.com:18789",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(report.ok).toBe(false);
|
|
37
|
+
expect(report.findings.some((finding) => finding.code === "gateway_remote_url_insecure_ws")).toBe(
|
|
38
|
+
true,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/index.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
|
3
|
+
|
|
4
|
+
type GatewayCheckLevel = "error" | "warning";
|
|
5
|
+
|
|
6
|
+
type GatewayCheckFinding = {
|
|
7
|
+
level: GatewayCheckLevel;
|
|
8
|
+
code: string;
|
|
9
|
+
message: string;
|
|
10
|
+
fix?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type GatewayCheckReport = {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
checkedAt: string;
|
|
16
|
+
findings: GatewayCheckFinding[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function hasSecretLikeValue(value: unknown): boolean {
|
|
20
|
+
if (typeof value === "string") {
|
|
21
|
+
return value.trim().length > 0;
|
|
22
|
+
}
|
|
23
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return Object.keys(value as Record<string, unknown>).length > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
30
|
+
const host = hostname.trim().toLowerCase();
|
|
31
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function checkGatewayConfig(cfg: OpenClawConfig): GatewayCheckFinding[] {
|
|
35
|
+
const findings: GatewayCheckFinding[] = [];
|
|
36
|
+
const gateway = cfg.gateway ?? {};
|
|
37
|
+
const mode = gateway.mode;
|
|
38
|
+
|
|
39
|
+
if (mode !== "local" && mode !== "remote") {
|
|
40
|
+
findings.push({
|
|
41
|
+
level: "error",
|
|
42
|
+
code: "gateway_mode_missing",
|
|
43
|
+
message: "gateway.mode must be set to \"local\" or \"remote\".",
|
|
44
|
+
fix: "Run: openclaw config set gateway.mode local",
|
|
45
|
+
});
|
|
46
|
+
return findings;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const authMode = gateway.auth?.mode;
|
|
50
|
+
const authTokenConfigured = hasSecretLikeValue(gateway.auth?.token);
|
|
51
|
+
const authPasswordConfigured = hasSecretLikeValue(gateway.auth?.password);
|
|
52
|
+
|
|
53
|
+
if (!authMode && authTokenConfigured && authPasswordConfigured) {
|
|
54
|
+
findings.push({
|
|
55
|
+
level: "error",
|
|
56
|
+
code: "gateway_auth_mode_ambiguous",
|
|
57
|
+
message:
|
|
58
|
+
"gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset.",
|
|
59
|
+
fix: "Run: openclaw config set gateway.auth.mode token",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (mode === "local") {
|
|
64
|
+
if (authMode === "token" && !authTokenConfigured) {
|
|
65
|
+
findings.push({
|
|
66
|
+
level: "error",
|
|
67
|
+
code: "gateway_auth_token_missing",
|
|
68
|
+
message: "gateway.auth.mode is token but gateway.auth.token is missing.",
|
|
69
|
+
fix: "Run: openclaw config set gateway.auth.token \"<token>\"",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (authMode === "password" && !authPasswordConfigured) {
|
|
74
|
+
findings.push({
|
|
75
|
+
level: "error",
|
|
76
|
+
code: "gateway_auth_password_missing",
|
|
77
|
+
message: "gateway.auth.mode is password but gateway.auth.password is missing.",
|
|
78
|
+
fix: "Run: openclaw config set gateway.auth.password \"<password>\"",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (authMode === "trusted-proxy" && !gateway.auth?.trustedProxy?.userHeader?.trim()) {
|
|
83
|
+
findings.push({
|
|
84
|
+
level: "error",
|
|
85
|
+
code: "gateway_auth_trusted_proxy_missing_user_header",
|
|
86
|
+
message:
|
|
87
|
+
"gateway.auth.mode is trusted-proxy but gateway.auth.trustedProxy.userHeader is missing.",
|
|
88
|
+
fix: "Set gateway.auth.trustedProxy.userHeader to the proxy identity header.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof gateway.remote?.url === "string" && gateway.remote.url.trim()) {
|
|
93
|
+
findings.push({
|
|
94
|
+
level: "warning",
|
|
95
|
+
code: "gateway_remote_url_ignored_in_local_mode",
|
|
96
|
+
message: "gateway.remote.url is set but gateway.mode is local; remote URL will be ignored.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return findings;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const remoteUrlRaw = gateway.remote?.url;
|
|
104
|
+
const remoteUrl = typeof remoteUrlRaw === "string" ? remoteUrlRaw.trim() : "";
|
|
105
|
+
if (!remoteUrl) {
|
|
106
|
+
findings.push({
|
|
107
|
+
level: "error",
|
|
108
|
+
code: "gateway_remote_url_missing",
|
|
109
|
+
message: "gateway.mode is remote but gateway.remote.url is missing.",
|
|
110
|
+
fix: "Run: openclaw config set gateway.remote.url \"wss://host:port\"",
|
|
111
|
+
});
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let parsedUrl: URL;
|
|
116
|
+
try {
|
|
117
|
+
parsedUrl = new URL(remoteUrl);
|
|
118
|
+
} catch {
|
|
119
|
+
findings.push({
|
|
120
|
+
level: "error",
|
|
121
|
+
code: "gateway_remote_url_invalid",
|
|
122
|
+
message: `gateway.remote.url is not a valid URL: ${remoteUrl}`,
|
|
123
|
+
fix: "Use ws:// or wss:// URL format.",
|
|
124
|
+
});
|
|
125
|
+
return findings;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") {
|
|
129
|
+
findings.push({
|
|
130
|
+
level: "error",
|
|
131
|
+
code: "gateway_remote_url_protocol_invalid",
|
|
132
|
+
message: `gateway.remote.url must start with ws:// or wss:// (got ${parsedUrl.protocol}).`,
|
|
133
|
+
fix: "Use wss://host:port for remote gateway access.",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) {
|
|
138
|
+
findings.push({
|
|
139
|
+
level: "error",
|
|
140
|
+
code: "gateway_remote_url_insecure_ws",
|
|
141
|
+
message:
|
|
142
|
+
"gateway.remote.url uses insecure ws:// for a non-loopback host; this can expose credentials and traffic.",
|
|
143
|
+
fix: "Use wss://, or keep gateway on loopback and tunnel via SSH/Tailscale.",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (gateway.remote?.transport === "ssh" && !gateway.remote?.sshTarget?.trim()) {
|
|
148
|
+
findings.push({
|
|
149
|
+
level: "warning",
|
|
150
|
+
code: "gateway_remote_ssh_target_missing",
|
|
151
|
+
message: "gateway.remote.transport is ssh but gateway.remote.sshTarget is missing.",
|
|
152
|
+
fix: "Run: openclaw config set gateway.remote.sshTarget \"user@host\"",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return findings;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function runGatewayConfigCheck(cfg: OpenClawConfig): GatewayCheckReport {
|
|
160
|
+
const findings = checkGatewayConfig(cfg);
|
|
161
|
+
const hasErrors = findings.some((finding) => finding.level === "error");
|
|
162
|
+
return {
|
|
163
|
+
ok: !hasErrors,
|
|
164
|
+
checkedAt: new Date().toISOString(),
|
|
165
|
+
findings,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function printGatewayCheckReport(report: GatewayCheckReport): void {
|
|
170
|
+
if (report.findings.length === 0) {
|
|
171
|
+
console.log("Gateway config check passed.");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const errors = report.findings.filter((finding) => finding.level === "error");
|
|
176
|
+
const warnings = report.findings.filter((finding) => finding.level === "warning");
|
|
177
|
+
|
|
178
|
+
if (errors.length > 0) {
|
|
179
|
+
console.log(`Gateway config check failed with ${errors.length} error(s).`);
|
|
180
|
+
for (const finding of errors) {
|
|
181
|
+
console.log(`- [${finding.code}] ${finding.message}`);
|
|
182
|
+
if (finding.fix) {
|
|
183
|
+
console.log(` Fix: ${finding.fix}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (warnings.length > 0) {
|
|
189
|
+
console.log(`Gateway config warnings: ${warnings.length}`);
|
|
190
|
+
for (const finding of warnings) {
|
|
191
|
+
console.log(`- [${finding.code}] ${finding.message}`);
|
|
192
|
+
if (finding.fix) {
|
|
193
|
+
console.log(` Hint: ${finding.fix}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const gwcheckPlugin = {
|
|
200
|
+
id: "gwcheck",
|
|
201
|
+
name: "Gateway Config Check",
|
|
202
|
+
description: "Adds a top-level gwcheck command for gateway config checks",
|
|
203
|
+
configSchema: emptyPluginConfigSchema(),
|
|
204
|
+
register(api: OpenClawPluginApi) {
|
|
205
|
+
api.registerCli(
|
|
206
|
+
({ program }) => {
|
|
207
|
+
program
|
|
208
|
+
.command("gwcheck")
|
|
209
|
+
.description("Check gateway config semantics")
|
|
210
|
+
.option("--json", "Output report as JSON", false)
|
|
211
|
+
.action((opts: { json?: boolean }) => {
|
|
212
|
+
let cfg: OpenClawConfig;
|
|
213
|
+
try {
|
|
214
|
+
cfg = api.runtime.config.loadConfig();
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const error = {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: `Failed to load config: ${String(err)}`,
|
|
219
|
+
};
|
|
220
|
+
if (opts.json) {
|
|
221
|
+
console.log(JSON.stringify(error, null, 2));
|
|
222
|
+
} else {
|
|
223
|
+
console.error(error.error);
|
|
224
|
+
}
|
|
225
|
+
process.exitCode = 1;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const report = runGatewayConfigCheck(cfg);
|
|
230
|
+
if (opts.json) {
|
|
231
|
+
console.log(JSON.stringify(report, null, 2));
|
|
232
|
+
} else {
|
|
233
|
+
printGatewayCheckReport(report);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!report.ok) {
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
{ commands: ["gwcheck"] },
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export default gwcheckPlugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wangzn04/openclaw-gwcheck",
|
|
3
|
+
"version": "2026.3.3",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "OpenClaw gateway config check plugin",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"peerDependencies": {
|
|
8
|
+
"openclaw": ">=2026.3.2"
|
|
9
|
+
},
|
|
10
|
+
"peerDependenciesMeta": {
|
|
11
|
+
"openclaw": {
|
|
12
|
+
"optional": true
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"openclaw": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./index.ts"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|