Skip to content

Commit 4062aa5

Browse files
Gateway: add safer password-file input for gateway run (openclaw#39067)
* CLI: add gateway password-file option * Docs: document safer gateway password input * Update src/cli/gateway-cli/run.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Tests: clean up gateway password temp dirs * CLI: restore gateway password warning flow * Security: harden secret file reads --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent 31564be commit 4062aa5

File tree

7 files changed

+189
-2
lines changed

7 files changed

+189
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ Docs: https://docs.openclaw.ai
136136
- Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into `openclaw/plugin-sdk/core` and `openclaw/plugin-sdk/telegram`, and preserve `api.runtime` reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy.
137137
- Plugins/startup performance: reduce bursty plugin discovery/manifest overhead with short in-process caches, skip importing bundled memory plugins that are disabled by slot selection, and speed legacy root `openclaw/plugin-sdk` compatibility via runtime root-alias routing while preserving backward compatibility. Thanks @gumadeiras.
138138
- Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras.
139+
- Gateway/password CLI hardening: add `openclaw gateway run --password-file`, warn when inline `--password` is used because it can leak via process listings, and document env/file-backed password input as the preferred startup path. Fixes #27948. Thanks @vibewrk and @vincentkoc.
139140
- Config/heartbeat legacy-path handling: auto-migrate top-level `heartbeat` into `agents.defaults.heartbeat` (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan.
140141
- Plugins/SDK subpath parity: expand plugin SDK subpaths across bundled channels/extensions (Discord, Slack, Signal, iMessage, WhatsApp, LINE, and bundled companion plugins), with build/export/type/runtime wiring so scoped imports resolve consistently in source and dist while preserving compatibility. (#33737) thanks @gumadeiras.
141142
- Plugins/bundled scoped-import migration: migrate bundled plugins from monolithic `openclaw/plugin-sdk` imports to scoped subpaths (or `openclaw/plugin-sdk/core`) across registration and startup-sensitive runtime files, add CI/release guardrails to prevent regressions, and keep root `openclaw/plugin-sdk` support for external/community plugins. Thanks @gumadeiras.

docs/cli/gateway.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ Notes:
4646
- `--bind <loopback|lan|tailnet|auto|custom>`: listener bind mode.
4747
- `--auth <token|password>`: auth mode override.
4848
- `--token <token>`: token override (also sets `OPENCLAW_GATEWAY_TOKEN` for the process).
49-
- `--password <password>`: password override (also sets `OPENCLAW_GATEWAY_PASSWORD` for the process).
49+
- `--password <password>`: password override. Warning: inline passwords can be exposed in local process listings.
50+
- `--password-file <path>`: read the gateway password from a file.
5051
- `--tailscale <off|serve|funnel>`: expose the Gateway via Tailscale.
5152
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
5253
- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config.
@@ -170,6 +171,7 @@ Notes:
170171
- `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`.
171172
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
172173
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed instead of persisting fallback plaintext.
174+
- For password auth on `gateway run`, prefer `OPENCLAW_GATEWAY_PASSWORD`, `--password-file`, or a SecretRef-backed `gateway.auth.password` over inline `--password`.
173175
- In inferred auth mode, shell-only `OPENCLAW_GATEWAY_PASSWORD`/`CLAWDBOT_GATEWAY_PASSWORD` does not relax install token requirements; use durable config (`gateway.auth.password` or config `env`) when installing a managed service.
174176
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly.
175177
- Lifecycle commands accept `--json` for scripting.

docs/cli/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,7 @@ Options:
745745
- `--token <token>`
746746
- `--auth <token|password>`
747747
- `--password <password>`
748+
- `--password-file <path>`
748749
- `--tailscale <off|serve|funnel>`
749750
- `--tailscale-reset-on-exit`
750751
- `--allow-unconfigured`

src/acp/secret-file.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { mkdir, symlink, writeFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
5+
import { MAX_SECRET_FILE_BYTES, readSecretFromFile } from "./secret-file.js";
6+
7+
const tempDirs = createTrackedTempDirs();
8+
const createTempDir = () => tempDirs.make("openclaw-secret-file-test-");
9+
10+
afterEach(async () => {
11+
await tempDirs.cleanup();
12+
});
13+
14+
describe("readSecretFromFile", () => {
15+
it("reads and trims a regular secret file", async () => {
16+
const dir = await createTempDir();
17+
const file = path.join(dir, "secret.txt");
18+
await writeFile(file, " top-secret \n", "utf8");
19+
20+
expect(readSecretFromFile(file, "Gateway password")).toBe("top-secret");
21+
});
22+
23+
it("rejects files larger than the secret-file limit", async () => {
24+
const dir = await createTempDir();
25+
const file = path.join(dir, "secret.txt");
26+
await writeFile(file, "x".repeat(MAX_SECRET_FILE_BYTES + 1), "utf8");
27+
28+
expect(() => readSecretFromFile(file, "Gateway password")).toThrow(
29+
`Gateway password file at ${file} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`,
30+
);
31+
});
32+
33+
it("rejects non-regular files", async () => {
34+
const dir = await createTempDir();
35+
const nestedDir = path.join(dir, "secret-dir");
36+
await mkdir(nestedDir);
37+
38+
expect(() => readSecretFromFile(nestedDir, "Gateway password")).toThrow(
39+
`Gateway password file at ${nestedDir} must be a regular file.`,
40+
);
41+
});
42+
43+
it("rejects symlinks", async () => {
44+
const dir = await createTempDir();
45+
const target = path.join(dir, "target.txt");
46+
const link = path.join(dir, "secret-link.txt");
47+
await writeFile(target, "top-secret\n", "utf8");
48+
await symlink(target, link);
49+
50+
expect(() => readSecretFromFile(link, "Gateway password")).toThrow(
51+
`Gateway password file at ${link} must not be a symlink.`,
52+
);
53+
});
54+
});

src/acp/secret-file.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,32 @@
11
import fs from "node:fs";
22
import { resolveUserPath } from "../utils.js";
33

4+
export const MAX_SECRET_FILE_BYTES = 16 * 1024;
5+
46
export function readSecretFromFile(filePath: string, label: string): string {
57
const resolvedPath = resolveUserPath(filePath.trim());
68
if (!resolvedPath) {
79
throw new Error(`${label} file path is empty.`);
810
}
11+
12+
let stat: fs.Stats;
13+
try {
14+
stat = fs.lstatSync(resolvedPath);
15+
} catch (err) {
16+
throw new Error(`Failed to inspect ${label} file at ${resolvedPath}: ${String(err)}`, {
17+
cause: err,
18+
});
19+
}
20+
if (stat.isSymbolicLink()) {
21+
throw new Error(`${label} file at ${resolvedPath} must not be a symlink.`);
22+
}
23+
if (!stat.isFile()) {
24+
throw new Error(`${label} file at ${resolvedPath} must be a regular file.`);
25+
}
26+
if (stat.size > MAX_SECRET_FILE_BYTES) {
27+
throw new Error(`${label} file at ${resolvedPath} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`);
28+
}
29+
930
let raw = "";
1031
try {
1132
raw = fs.readFileSync(resolvedPath, "utf8");

src/cli/gateway-cli/run.option-collisions.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
14
import { Command } from "commander";
25
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
36
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
@@ -239,4 +242,77 @@ describe("gateway run option collisions", () => {
239242
}),
240243
);
241244
});
245+
246+
it("reads gateway password from --password-file", async () => {
247+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-run-"));
248+
try {
249+
const passwordFile = path.join(tempDir, "gateway-password.txt");
250+
await fs.writeFile(passwordFile, "pw_from_file\n", "utf8");
251+
252+
await runGatewayCli([
253+
"gateway",
254+
"run",
255+
"--auth",
256+
"password",
257+
"--password-file",
258+
passwordFile,
259+
"--allow-unconfigured",
260+
]);
261+
262+
expect(startGatewayServer).toHaveBeenCalledWith(
263+
18789,
264+
expect.objectContaining({
265+
auth: expect.objectContaining({
266+
mode: "password",
267+
password: "pw_from_file",
268+
}),
269+
}),
270+
);
271+
expect(runtimeErrors).not.toContain(
272+
"Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.",
273+
);
274+
} finally {
275+
await fs.rm(tempDir, { recursive: true, force: true });
276+
}
277+
});
278+
279+
it("warns when gateway password is passed inline", async () => {
280+
await runGatewayCli([
281+
"gateway",
282+
"run",
283+
"--auth",
284+
"password",
285+
"--password",
286+
"pw_inline",
287+
"--allow-unconfigured",
288+
]);
289+
290+
expect(runtimeErrors).toContain(
291+
"Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.",
292+
);
293+
});
294+
295+
it("rejects using both --password and --password-file", async () => {
296+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-run-"));
297+
try {
298+
const passwordFile = path.join(tempDir, "gateway-password.txt");
299+
await fs.writeFile(passwordFile, "pw_from_file\n", "utf8");
300+
301+
await expect(
302+
runGatewayCli([
303+
"gateway",
304+
"run",
305+
"--password",
306+
"pw_inline",
307+
"--password-file",
308+
passwordFile,
309+
"--allow-unconfigured",
310+
]),
311+
).rejects.toThrow("__exit__:1");
312+
313+
expect(runtimeErrors).toContain("Use either --password or --password-file.");
314+
} finally {
315+
await fs.rm(tempDir, { recursive: true, force: true });
316+
}
317+
});
242318
});

src/cli/gateway-cli/run.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "node:fs";
22
import path from "node:path";
33
import type { Command } from "commander";
4+
import { readSecretFromFile } from "../../acp/secret-file.js";
45
import type { GatewayAuthMode, GatewayTailscaleMode } from "../../config/config.js";
56
import {
67
CONFIG_PATH,
@@ -40,6 +41,7 @@ type GatewayRunOpts = {
4041
token?: unknown;
4142
auth?: unknown;
4243
password?: unknown;
44+
passwordFile?: unknown;
4345
tailscale?: unknown;
4446
tailscaleResetOnExit?: boolean;
4547
allowUnconfigured?: boolean;
@@ -62,6 +64,7 @@ const GATEWAY_RUN_VALUE_KEYS = [
6264
"token",
6365
"auth",
6466
"password",
67+
"passwordFile",
6568
"tailscale",
6669
"wsLog",
6770
"rawStreamPath",
@@ -87,6 +90,24 @@ const GATEWAY_AUTH_MODES: readonly GatewayAuthMode[] = [
8790
];
8891
const GATEWAY_TAILSCALE_MODES: readonly GatewayTailscaleMode[] = ["off", "serve", "funnel"];
8992

93+
function warnInlinePasswordFlag() {
94+
defaultRuntime.error(
95+
"Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.",
96+
);
97+
}
98+
99+
function resolveGatewayPasswordOption(opts: GatewayRunOpts): string | undefined {
100+
const direct = toOptionString(opts.password);
101+
const file = toOptionString(opts.passwordFile);
102+
if (direct && file) {
103+
throw new Error("Use either --password or --password-file.");
104+
}
105+
if (file) {
106+
return readSecretFromFile(file, "Gateway password");
107+
}
108+
return direct;
109+
}
110+
90111
function parseEnumOption<T extends string>(
91112
raw: string | undefined,
92113
allowed: readonly T[],
@@ -277,7 +298,17 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
277298
defaultRuntime.exit(1);
278299
return;
279300
}
280-
const passwordRaw = toOptionString(opts.password);
301+
let passwordRaw: string | undefined;
302+
try {
303+
passwordRaw = resolveGatewayPasswordOption(opts);
304+
} catch (err) {
305+
defaultRuntime.error(err instanceof Error ? err.message : String(err));
306+
defaultRuntime.exit(1);
307+
return;
308+
}
309+
if (toOptionString(opts.password)) {
310+
warnInlinePasswordFlag();
311+
}
281312
const tokenRaw = toOptionString(opts.token);
282313

283314
const snapshot = await readConfigFileSnapshot().catch(() => null);
@@ -439,6 +470,7 @@ export function addGatewayRunCommand(cmd: Command): Command {
439470
)
440471
.option("--auth <mode>", `Gateway auth mode (${formatModeChoices(GATEWAY_AUTH_MODES)})`)
441472
.option("--password <password>", "Password for auth mode=password")
473+
.option("--password-file <path>", "Read gateway password from file")
442474
.option(
443475
"--tailscale <mode>",
444476
`Tailscale exposure mode (${formatModeChoices(GATEWAY_TAILSCALE_MODES)})`,

0 commit comments

Comments
 (0)