gateway: harden shared auth resolution across systemd, discord, and node host

This commit is contained in:
Josh Avant 2026-03-07 18:28:32 -06:00 committed by GitHub
parent a7f6e0a921
commit 25252ab5ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1498 additions and 255 deletions

View File

@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai
- iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth.
- Skills/workspace boundary hardening: reject workspace and extra-dir skill roots or `SKILL.md` files whose realpath escapes the configured source root, and skip syncing those escaped skills into sandbox workspaces.
- Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant.
- gateway: harden shared auth resolution across systemd, discord, and node host (#39241) Thanks @joshavant.
- Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant.
- Sessions/subagent attachments: remove `attachments[].content.maxLength` from `sessions_spawn` schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera.
- Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.

View File

@ -539,6 +539,7 @@ public struct AgentParams: Codable, Sendable {
public let idempotencykey: String
public let label: String?
public let spawnedby: String?
public let workspacedir: String?
public init(
message: String,
@ -566,7 +567,8 @@ public struct AgentParams: Codable, Sendable {
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
label: String?,
spawnedby: String?)
spawnedby: String?,
workspacedir: String?)
{
self.message = message
self.agentid = agentid
@ -594,6 +596,7 @@ public struct AgentParams: Codable, Sendable {
self.idempotencykey = idempotencykey
self.label = label
self.spawnedby = spawnedby
self.workspacedir = workspacedir
}
private enum CodingKeys: String, CodingKey {
@ -623,6 +626,7 @@ public struct AgentParams: Codable, Sendable {
case idempotencykey = "idempotencyKey"
case label
case spawnedby = "spawnedBy"
case workspacedir = "workspaceDir"
}
}

View File

@ -539,6 +539,7 @@ public struct AgentParams: Codable, Sendable {
public let idempotencykey: String
public let label: String?
public let spawnedby: String?
public let workspacedir: String?
public init(
message: String,
@ -566,7 +567,8 @@ public struct AgentParams: Codable, Sendable {
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
label: String?,
spawnedby: String?)
spawnedby: String?,
workspacedir: String?)
{
self.message = message
self.agentid = agentid
@ -594,6 +596,7 @@ public struct AgentParams: Codable, Sendable {
self.idempotencykey = idempotencykey
self.label = label
self.spawnedby = spawnedby
self.workspacedir = workspacedir
}
private enum CodingKeys: String, CodingKey {
@ -623,6 +626,7 @@ public struct AgentParams: Codable, Sendable {
case idempotencykey = "idempotencyKey"
case label
case spawnedby = "spawnedBy"
case workspacedir = "workspaceDir"
}
}

View File

@ -942,6 +942,13 @@ Default slash command settings:
When `target` is `channel` or `both`, the approval prompt is visible in the channel. Only configured approvers can use the buttons; other users receive an ephemeral denial. Approval prompts include the command text, so only enable channel delivery in trusted channels. If the channel ID cannot be derived from the session key, OpenClaw falls back to DM delivery.
Gateway auth for this handler uses the same shared credential resolution contract as other Gateway clients:
- env-first local auth (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` then `gateway.auth.*`)
- in local mode, `gateway.remote.*` can be used as fallback when `gateway.auth.*` is unset
- remote-mode support via `gateway.remote.*` when applicable
- URL overrides are override-safe: CLI overrides do not reuse implicit credentials, and env overrides use env credentials only
If approvals fail with unknown approval IDs, verify approver list and feature enablement.
Related docs: [Exec approvals](/tools/exec-approvals)

View File

@ -179,6 +179,10 @@ Security note:
- `--token` and `--password` can be visible in local process listings on some systems.
- Prefer `--token-file`/`--password-file` or environment variables (`OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_GATEWAY_PASSWORD`).
- Gateway auth resolution follows the shared contract used by other Gateway clients:
- local mode: env (`OPENCLAW_GATEWAY_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback when `gateway.auth.*` is unset
- remote mode: `gateway.remote.*` with env/config fallback per remote precedence rules
- `--url` is override-safe and does not reuse implicit config/env credentials; pass explicit `--token`/`--password` (or file variants)
- ACP runtime backend child processes receive `OPENCLAW_SHELL=acp`, which can be used for context-specific shell/profile rules.
- `openclaw acp client` sets `OPENCLAW_SHELL=acp-client` on the spawned bridge process.

View File

@ -41,6 +41,7 @@ openclaw daemon uninstall
Notes:
- `status` resolves configured auth SecretRefs for probe auth when possible.
- On Linux systemd installs, `status` token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources.
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed.
- 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.

View File

@ -109,6 +109,7 @@ Notes:
- `gateway status` resolves configured auth SecretRefs for probe auth when possible.
- If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first.
- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files).
### `gateway probe`

View File

@ -777,6 +777,7 @@ Notes:
- `gateway status` supports `--no-probe`, `--deep`, and `--json` for scripting.
- `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra".
- `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL.
- On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources.
- `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
- `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
- `gateway install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
@ -1010,6 +1011,11 @@ Subcommands:
- `node stop`
- `node restart`
Auth notes:
- `node` resolves gateway auth from env/config (no `--token`/`--password` flags): `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`, then `gateway.auth.*`, with remote-mode support via `gateway.remote.*`.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored for node-host auth resolution.
## Nodes
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).

View File

@ -58,6 +58,16 @@ Options:
- `--node-id <id>`: Override node id (clears pairing token)
- `--display-name <name>`: Override the node display name
## Gateway auth for node host
`openclaw node run` and `openclaw node install` resolve gateway auth from config/env (no `--token`/`--password` flags on node commands):
- `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first.
- Then local config fallback: `gateway.auth.token` / `gateway.auth.password`.
- In local mode, `gateway.remote.token` / `gateway.remote.password` are also eligible as fallback when `gateway.auth.*` is unset.
- In `gateway.mode=remote`, remote client fields (`gateway.remote.token` / `gateway.remote.password`) are also eligible per remote precedence rules.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are ignored for node host auth resolution.
## Service (background)
Install a headless node host as a user service.

View File

@ -278,6 +278,7 @@ Notes:
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, doctor service install/repair validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, doctor blocks the install/repair path with actionable guidance.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, doctor blocks install/repair until mode is set explicitly.
- For Linux user-systemd units, doctor token drift checks now include both `Environment=` and `EnvironmentFile=` sources when comparing service auth metadata.
- You can always force a full rewrite via `openclaw gateway install --force`.
### 16) Gateway runtime + port diagnostics

View File

@ -103,9 +103,12 @@ When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and op
## Credential precedence
Gateway call/probe credential resolution now follows one shared contract:
Gateway credential resolution follows one shared contract across call/probe/status paths, Discord exec-approval monitoring, and node-host connections:
- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win.
- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win on call paths that accept explicit auth.
- URL override safety:
- CLI URL overrides (`--url`) never reuse implicit config/env credentials.
- Env URL overrides (`OPENCLAW_GATEWAY_URL`) may use env credentials only (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
- Local mode defaults:
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password`

View File

@ -81,8 +81,10 @@ openclaw node run --host 127.0.0.1 --port 18790 --display-name "Build Node"
Notes:
- The token is `gateway.auth.token` from the gateway config (`~/.openclaw/openclaw.json` on the gateway host).
- `openclaw node run` reads `OPENCLAW_GATEWAY_TOKEN` for auth.
- `openclaw node run` supports token or password auth.
- Env vars are preferred: `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`.
- Config fallback is `gateway.auth.token` / `gateway.auth.password`; in remote mode, `gateway.remote.token` / `gateway.remote.password` are also eligible.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored by node-host auth resolution.
### Start a node host (service)

View File

@ -10,19 +10,17 @@ type GatewayClientAuth = {
token?: string;
password?: string;
};
type ResolveGatewayCredentialsWithSecretInputs = (params: unknown) => Promise<GatewayClientAuth>;
type ResolveGatewayConnectionAuth = (params: unknown) => Promise<GatewayClientAuth>;
const mockState = {
gateways: [] as MockGatewayClient[],
gatewayAuth: [] as GatewayClientAuth[],
agentSideConnectionCtor: vi.fn(),
agentStart: vi.fn(),
resolveGatewayCredentialsWithSecretInputs: vi.fn<ResolveGatewayCredentialsWithSecretInputs>(
async (_params) => ({
resolveGatewayConnectionAuth: vi.fn<ResolveGatewayConnectionAuth>(async (_params) => ({
token: undefined,
password: undefined,
}),
),
})),
};
class MockGatewayClient {
@ -72,11 +70,22 @@ vi.mock("../gateway/auth.js", () => ({
}));
vi.mock("../gateway/call.js", () => ({
buildGatewayConnectionDetails: () => ({
buildGatewayConnectionDetails: ({ url }: { url?: string }) => {
if (typeof url === "string" && url.trim().length > 0) {
return {
url: url.trim(),
urlSource: "cli --url",
};
}
return {
url: "ws://127.0.0.1:18789",
}),
resolveGatewayCredentialsWithSecretInputs: (params: unknown) =>
mockState.resolveGatewayCredentialsWithSecretInputs(params),
urlSource: "local loopback",
};
},
}));
vi.mock("../gateway/connection-auth.js", () => ({
resolveGatewayConnectionAuth: (params: unknown) => mockState.resolveGatewayConnectionAuth(params),
}));
vi.mock("../gateway/client.js", () => ({
@ -129,8 +138,8 @@ describe("serveAcpGateway startup", () => {
mockState.gatewayAuth.length = 0;
mockState.agentSideConnectionCtor.mockReset();
mockState.agentStart.mockReset();
mockState.resolveGatewayCredentialsWithSecretInputs.mockReset();
mockState.resolveGatewayCredentialsWithSecretInputs.mockResolvedValue({
mockState.resolveGatewayConnectionAuth.mockReset();
mockState.resolveGatewayConnectionAuth.mockResolvedValue({
token: undefined,
password: undefined,
});
@ -178,7 +187,7 @@ describe("serveAcpGateway startup", () => {
});
it("passes resolved SecretInput gateway credentials to the ACP gateway client", async () => {
mockState.resolveGatewayCredentialsWithSecretInputs.mockResolvedValue({
mockState.resolveGatewayConnectionAuth.mockResolvedValue({
token: undefined,
password: "resolved-secret-password", // pragma: allowlist secret
});
@ -188,7 +197,7 @@ describe("serveAcpGateway startup", () => {
const servePromise = serveAcpGateway({});
await Promise.resolve();
expect(mockState.resolveGatewayCredentialsWithSecretInputs).toHaveBeenCalledWith(
expect(mockState.resolveGatewayConnectionAuth).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
}),
@ -209,4 +218,33 @@ describe("serveAcpGateway startup", () => {
onceSpy.mockRestore();
}
});
it("passes CLI URL override context into shared gateway auth resolution", async () => {
const { signalHandlers, onceSpy } = captureProcessSignalHandlers();
try {
const servePromise = serveAcpGateway({
gatewayUrl: "wss://override.example/ws",
});
await Promise.resolve();
expect(mockState.resolveGatewayConnectionAuth).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
urlOverride: "wss://override.example/ws",
urlOverrideSource: "cli",
}),
);
const gateway = getMockGateway();
gateway.emitHello();
await vi.waitFor(() => {
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);
});
signalHandlers.get("SIGINT")?.();
await servePromise;
} finally {
onceSpy.mockRestore();
}
});
});

View File

@ -3,11 +3,9 @@ import { Readable, Writable } from "node:stream";
import { fileURLToPath } from "node:url";
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
import { loadConfig } from "../config/config.js";
import {
buildGatewayConnectionDetails,
resolveGatewayCredentialsWithSecretInputs,
} from "../gateway/call.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
import { isMainModule } from "../infra/is-main.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { readSecretFromFile } from "./secret-file.js";
@ -20,13 +18,21 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void
config: cfg,
url: opts.gatewayUrl,
});
const creds = await resolveGatewayCredentialsWithSecretInputs({
const gatewayUrlOverrideSource =
connection.urlSource === "cli --url"
? "cli"
: connection.urlSource === "env OPENCLAW_GATEWAY_URL"
? "env"
: undefined;
const creds = await resolveGatewayConnectionAuth({
config: cfg,
explicitAuth: {
token: opts.gatewayToken,
password: opts.gatewayPassword,
},
env: process.env,
urlOverride: gatewayUrlOverrideSource ? connection.url : undefined,
urlOverrideSource: gatewayUrlOverrideSource,
});
let agent: AcpGatewayAgent | null = null;

View File

@ -66,6 +66,8 @@ describe("runServiceRestart token drift", () => {
vi.unstubAllEnvs();
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "");
vi.stubEnv("OPENCLAW_GATEWAY_URL", "");
vi.stubEnv("CLAWDBOT_GATEWAY_URL", "");
});
it("emits drift warning when enabled", async () => {
@ -80,7 +82,9 @@ describe("runServiceRestart token drift", () => {
expect(loadConfig).toHaveBeenCalledTimes(1);
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] };
expect(payload.warnings?.[0]).toContain("gateway install --force");
expect(payload.warnings).toEqual(
expect.arrayContaining([expect.stringContaining("gateway install --force")]),
);
});
it("uses gateway.auth.token when checking drift", async () => {
@ -106,7 +110,9 @@ describe("runServiceRestart token drift", () => {
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] };
expect(payload.warnings?.[0]).toContain("gateway install --force");
expect(payload.warnings).toEqual(
expect.arrayContaining([expect.stringContaining("gateway install --force")]),
);
});
it("skips drift warning when disabled", async () => {

View File

@ -12,6 +12,7 @@ import { parseSystemdExecStart } from "./systemd-unit.js";
import {
isSystemdUserServiceAvailable,
parseSystemdShow,
readSystemdServiceExecStart,
restartSystemdService,
resolveSystemdUserUnitPath,
stopSystemdService,
@ -42,6 +43,19 @@ const createWritableStreamMock = () => {
};
};
function pathLikeToString(pathname: unknown): string {
if (typeof pathname === "string") {
return pathname;
}
if (pathname instanceof URL) {
return pathname.pathname;
}
if (pathname instanceof Uint8Array) {
return Buffer.from(pathname).toString("utf8");
}
return "";
}
const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => {
const { write, stdout } = createWritableStreamMock();
await restartSystemdService({ stdout, env });
@ -297,6 +311,173 @@ describe("parseSystemdExecStart", () => {
});
});
describe("readSystemdServiceExecStart", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("loads OPENCLAW_GATEWAY_TOKEN from EnvironmentFile", async () => {
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/.env",
].join("\n");
}
if (pathValue === "/home/test/.openclaw/.env") {
return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n";
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("env-file-token");
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
it("lets inline Environment override EnvironmentFile values", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/.env",
'Environment="OPENCLAW_GATEWAY_TOKEN=inline-token"',
].join("\n");
}
if (pathValue === "/home/test/.openclaw/.env") {
return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n";
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("inline-token");
});
it("ignores missing optional EnvironmentFile entries", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=-%h/.openclaw/missing.env",
].join("\n");
}
throw new Error(`missing: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]);
expect(command?.environment).toBeUndefined();
});
it("keeps parsing when non-optional EnvironmentFile entries are missing", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/missing.env",
].join("\n");
}
throw new Error(`missing: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]);
expect(command?.environment).toBeUndefined();
});
it("supports multiple EnvironmentFile entries and quoted paths", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
'EnvironmentFile=%h/.openclaw/first.env "%h/.openclaw/second env.env"',
].join("\n");
}
if (pathValue === "/home/test/.openclaw/first.env") {
return "OPENCLAW_GATEWAY_TOKEN=first-token\n";
}
if (pathValue === "/home/test/.openclaw/second env.env") {
return 'OPENCLAW_GATEWAY_PASSWORD="second password"\n';
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment).toEqual({
OPENCLAW_GATEWAY_TOKEN: "first-token",
OPENCLAW_GATEWAY_PASSWORD: "second password", // pragma: allowlist secret
});
});
it("resolves relative EnvironmentFile paths from the unit directory", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=./gateway.env ./override.env",
].join("\n");
}
if (pathValue.endsWith("/.config/systemd/user/gateway.env")) {
return [
"OPENCLAW_GATEWAY_TOKEN=relative-token",
"OPENCLAW_GATEWAY_PASSWORD=relative-password",
].join("\n");
}
if (pathValue.endsWith("/.config/systemd/user/override.env")) {
return "OPENCLAW_GATEWAY_TOKEN=override-token\n";
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment).toEqual({
OPENCLAW_GATEWAY_TOKEN: "override-token",
OPENCLAW_GATEWAY_PASSWORD: "relative-password", // pragma: allowlist secret
});
});
it("parses EnvironmentFile content with comments and quoted values", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/gateway.env",
].join("\n");
}
if (pathValue === "/home/test/.openclaw/gateway.env") {
return [
"# comment",
"; another comment",
'OPENCLAW_GATEWAY_TOKEN="quoted token"',
"OPENCLAW_GATEWAY_PASSWORD=quoted-password",
].join("\n");
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment).toEqual({
OPENCLAW_GATEWAY_TOKEN: "quoted token",
OPENCLAW_GATEWAY_PASSWORD: "quoted-password", // pragma: allowlist secret
});
});
});
describe("systemd service control", () => {
const assertMachineRestartArgs = (args: string[]) => {
expect(args).toEqual(["--machine", "debian@", "--user", "restart", "openclaw-gateway.service"]);

View File

@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { splitArgsPreservingQuotes } from "./arg-split.js";
import {
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
resolveGatewayServiceDescription,
@ -65,6 +66,7 @@ export async function readSystemdServiceExecStart(
let execStart = "";
let workingDirectory = "";
const environment: Record<string, string> = {};
const environmentFileSpecs: string[] = [];
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
@ -80,16 +82,30 @@ export async function readSystemdServiceExecStart(
if (parsed) {
environment[parsed.key] = parsed.value;
}
} else if (line.startsWith("EnvironmentFile=")) {
const raw = line.slice("EnvironmentFile=".length).trim();
if (raw) {
environmentFileSpecs.push(raw);
}
}
}
if (!execStart) {
return null;
}
const environmentFromFiles = await resolveSystemdEnvironmentFiles({
environmentFileSpecs,
env,
unitPath,
});
const mergedEnvironment = {
...environmentFromFiles,
...environment,
};
const programArguments = parseSystemdExecStart(execStart);
return {
programArguments,
...(workingDirectory ? { workingDirectory } : {}),
...(Object.keys(environment).length > 0 ? { environment } : {}),
...(Object.keys(mergedEnvironment).length > 0 ? { environment: mergedEnvironment } : {}),
sourcePath: unitPath,
};
} catch {
@ -97,6 +113,89 @@ export async function readSystemdServiceExecStart(
}
}
function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string {
// Support the common unit-specifier used in user services.
return input.replaceAll("%h", toPosixPath(resolveHomeDir(env)));
}
function parseEnvironmentFileSpecs(raw: string): string[] {
return splitArgsPreservingQuotes(raw, { escapeMode: "backslash" })
.map((entry) => entry.trim())
.filter(Boolean);
}
function parseEnvironmentFileLine(rawLine: string): { key: string; value: string } | null {
const trimmed = rawLine.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) {
return null;
}
const eq = trimmed.indexOf("=");
if (eq <= 0) {
return null;
}
const key = trimmed.slice(0, eq).trim();
if (!key) {
return null;
}
let value = trimmed.slice(eq + 1).trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
return { key, value };
}
async function readSystemdEnvironmentFile(pathname: string): Promise<Record<string, string>> {
const environment: Record<string, string> = {};
const content = await fs.readFile(pathname, "utf8");
for (const rawLine of content.split(/\r?\n/)) {
const parsed = parseEnvironmentFileLine(rawLine);
if (!parsed) {
continue;
}
environment[parsed.key] = parsed.value;
}
return environment;
}
async function resolveSystemdEnvironmentFiles(params: {
environmentFileSpecs: string[];
env: GatewayServiceEnv;
unitPath: string;
}): Promise<Record<string, string>> {
const resolved: Record<string, string> = {};
if (params.environmentFileSpecs.length === 0) {
return resolved;
}
const unitDir = path.posix.dirname(params.unitPath);
for (const specRaw of params.environmentFileSpecs) {
for (const token of parseEnvironmentFileSpecs(specRaw)) {
const optional = token.startsWith("-");
const pathnameRaw = optional ? token.slice(1).trim() : token;
if (!pathnameRaw) {
continue;
}
const expanded = expandSystemdSpecifier(pathnameRaw, params.env);
const pathname = path.posix.isAbsolute(expanded)
? expanded
: path.posix.resolve(unitDir, expanded);
try {
const fromFile = await readSystemdEnvironmentFile(pathname);
Object.assign(resolved, fromFile);
} catch {
// Keep service auditing resilient even when env files are unavailable
// in the current runtime context. Both optional and non-optional
// EnvironmentFile entries are skipped gracefully for diagnostics.
continue;
}
}
}
return resolved;
}
export type SystemdServiceInfo = {
activeState?: string;
subState?: string;

View File

@ -26,6 +26,25 @@ const writeStore = (store: Record<string, unknown>) => {
beforeEach(() => {
writeStore({});
mockGatewayClientCtor.mockClear();
mockResolveGatewayConnectionAuth.mockReset().mockImplementation(async (params: {
config?: {
gateway?: {
auth?: {
token?: string;
password?: string;
};
};
};
env: NodeJS.ProcessEnv;
}) => {
const configToken = params.config?.gateway?.auth?.token;
const configPassword = params.config?.gateway?.auth?.password;
const envToken = params.env.OPENCLAW_GATEWAY_TOKEN ?? params.env.CLAWDBOT_GATEWAY_TOKEN;
const envPassword =
params.env.OPENCLAW_GATEWAY_PASSWORD ?? params.env.CLAWDBOT_GATEWAY_PASSWORD;
return { token: envToken ?? configToken, password: envPassword ?? configPassword };
});
});
// ─── Mocks ────────────────────────────────────────────────────────────────────
@ -37,6 +56,8 @@ const gatewayClientStarts = vi.hoisted(() => vi.fn());
const gatewayClientStops = vi.hoisted(() => vi.fn());
const gatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const gatewayClientParams = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockGatewayClientCtor = vi.hoisted(() => vi.fn());
const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn());
vi.mock("../send.shared.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.shared.js")>();
@ -59,6 +80,7 @@ vi.mock("../../gateway/client.js", () => ({
constructor(params: Record<string, unknown>) {
this.params = params;
gatewayClientParams.push(params);
mockGatewayClientCtor(params);
}
start() {
gatewayClientStarts();
@ -72,6 +94,10 @@ vi.mock("../../gateway/client.js", () => ({
},
}));
vi.mock("../../gateway/connection-auth.js", () => ({
resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth,
}));
vi.mock("../../logger.js", () => ({
logDebug: vi.fn(),
logError: vi.fn(),
@ -776,3 +802,74 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
clearPendingTimeouts(handler);
});
});
describe("DiscordExecApprovalHandler gateway auth resolution", () => {
it("passes CLI URL overrides to shared gateway auth resolver", async () => {
mockResolveGatewayConnectionAuth.mockResolvedValue({
token: "resolved-token",
password: "resolved-password", // pragma: allowlist secret
});
const handler = new DiscordExecApprovalHandler({
token: "test-token",
accountId: "default",
gatewayUrl: "wss://override.example/ws",
config: { enabled: true, approvers: ["123"] },
cfg: { session: { store: STORE_PATH } },
});
await handler.start();
expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
urlOverride: "wss://override.example/ws",
urlOverrideSource: "cli",
}),
);
expect(mockGatewayClientCtor).toHaveBeenCalledWith(
expect.objectContaining({
url: "wss://override.example/ws",
token: "resolved-token",
password: "resolved-password", // pragma: allowlist secret
}),
);
await handler.stop();
});
it("passes env URL overrides to shared gateway auth resolver", async () => {
const previousGatewayUrl = process.env.OPENCLAW_GATEWAY_URL;
try {
process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-from-env.example/ws";
const handler = new DiscordExecApprovalHandler({
token: "test-token",
accountId: "default",
config: { enabled: true, approvers: ["123"] },
cfg: { session: { store: STORE_PATH } },
});
await handler.start();
expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
urlOverride: "wss://gateway-from-env.example/ws",
urlOverrideSource: "env",
}),
);
expect(mockGatewayClientCtor).toHaveBeenCalledWith(
expect.objectContaining({
url: "wss://gateway-from-env.example/ws",
}),
);
await handler.stop();
} finally {
if (typeof previousGatewayUrl === "string") {
process.env.OPENCLAW_GATEWAY_URL = previousGatewayUrl;
} else {
delete process.env.OPENCLAW_GATEWAY_URL;
}
}
});
});

View File

@ -15,7 +15,7 @@ import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
import { buildGatewayConnectionDetails } from "../../gateway/call.js";
import { GatewayClient } from "../../gateway/client.js";
import { resolveGatewayCredentialsFromConfig } from "../../gateway/credentials.js";
import { resolveGatewayConnectionAuth } from "../../gateway/connection-auth.js";
import type { EventFrame } from "../../gateway/protocol/index.js";
import type {
ExecApprovalDecision,
@ -401,18 +401,27 @@ export class DiscordExecApprovalHandler {
logDebug("discord exec approvals: starting handler");
const { url: gatewayUrl } = buildGatewayConnectionDetails({
const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({
config: this.opts.cfg,
url: this.opts.gatewayUrl,
});
const gatewayCredentials = resolveGatewayCredentialsFromConfig({
cfg: this.opts.cfg,
const gatewayUrlOverrideSource =
urlSource === "cli --url"
? "cli"
: urlSource === "env OPENCLAW_GATEWAY_URL"
? "env"
: undefined;
const auth = await resolveGatewayConnectionAuth({
config: this.opts.cfg,
env: process.env,
urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined,
urlOverrideSource: gatewayUrlOverrideSource,
});
this.gatewayClient = new GatewayClient({
url: gatewayUrl,
token: gatewayCredentials.token,
password: gatewayCredentials.password,
token: auth.token,
password: auth.password,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "Discord Exec Approvals",
mode: GATEWAY_CLIENT_MODES.BACKEND,

View File

@ -789,6 +789,30 @@ describe("callGateway password resolution", () => {
expect(lastClientOptions?.token).toBe("token-auth");
});
it("resolves local password ref before unresolved local token ref can block auth", async () => {
process.env.LOCAL_FALLBACK_PASSWORD = "resolved-local-fallback-password"; // pragma: allowlist secret
loadConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: {
token: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_TOKEN" },
password: { source: "env", provider: "default", id: "LOCAL_FALLBACK_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig);
await callGateway({ method: "health" });
expect(lastClientOptions?.token).toBeUndefined();
expect(lastClientOptions?.password).toBe("resolved-local-fallback-password"); // pragma: allowlist secret
});
it.each(["none", "trusted-proxy"] as const)(
"ignores unresolved local password ref when auth mode is %s",
async (mode) => {

View File

@ -6,7 +6,7 @@ import {
resolveGatewayPort,
resolveStateDir,
} from "../config/config.js";
import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js";
@ -19,10 +19,13 @@ import {
import { VERSION } from "../version.js";
import { GatewayClient } from "./client.js";
import {
readGatewayPasswordEnv,
readGatewayTokenEnv,
GatewaySecretRefUnavailableError,
resolveGatewayCredentialsFromConfig,
trimToUndefined,
type GatewayCredentialMode,
type GatewayCredentialPrecedence,
type GatewayRemoteCredentialFallback,
type GatewayRemoteCredentialPrecedence,
} from "./credentials.js";
import {
CLI_DEFAULT_OPERATOR_SCOPES,
@ -238,6 +241,14 @@ type ResolvedGatewayCallContext = {
urlOverrideSource?: "cli" | "env";
remoteUrl?: string;
explicitAuth: ExplicitGatewayAuth;
modeOverride?: GatewayCredentialMode;
includeLegacyEnv?: boolean;
localTokenPrecedence?: GatewayCredentialPrecedence;
localPasswordPrecedence?: GatewayCredentialPrecedence;
remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence;
remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence;
remoteTokenFallback?: GatewayRemoteCredentialFallback;
remotePasswordFallback?: GatewayRemoteCredentialFallback;
};
function resolveGatewayCallTimeout(timeoutValue: unknown): {
@ -303,6 +314,12 @@ async function resolveGatewaySecretInputString(params: {
value: params.value,
env: params.env,
normalize: trimToUndefined,
onResolveRefError: (error) => {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, {
cause: error,
});
},
});
if (!value) {
throw new Error(`${params.path} resolved to an empty or non-string value.`);
@ -330,166 +347,354 @@ async function resolveGatewayCredentialsWithEnv(
password: context.explicitAuth.password,
};
}
if (context.urlOverride) {
return resolveGatewayCredentialsFromConfig({
cfg: context.config,
return resolveGatewayCredentialsFromConfigWithSecretInputs({ context, env });
}
type SupportedGatewaySecretInputPath =
| "gateway.auth.token"
| "gateway.auth.password"
| "gateway.remote.token"
| "gateway.remote.password";
const ALL_GATEWAY_SECRET_INPUT_PATHS: SupportedGatewaySecretInputPath[] = [
"gateway.auth.token",
"gateway.auth.password",
"gateway.remote.token",
"gateway.remote.password",
];
function isSupportedGatewaySecretInputPath(path: string): path is SupportedGatewaySecretInputPath {
return (
path === "gateway.auth.token" ||
path === "gateway.auth.password" ||
path === "gateway.remote.token" ||
path === "gateway.remote.password"
);
}
function readGatewaySecretInputValue(
config: OpenClawConfig,
path: SupportedGatewaySecretInputPath,
): unknown {
if (path === "gateway.auth.token") {
return config.gateway?.auth?.token;
}
if (path === "gateway.auth.password") {
return config.gateway?.auth?.password;
}
if (path === "gateway.remote.token") {
return config.gateway?.remote?.token;
}
return config.gateway?.remote?.password;
}
function hasConfiguredGatewaySecretRef(
config: OpenClawConfig,
path: SupportedGatewaySecretInputPath,
): boolean {
return Boolean(
resolveSecretInputRef({
value: readGatewaySecretInputValue(config, path),
defaults: config.secrets?.defaults,
}).ref,
);
}
function resolveGatewayCredentialsFromConfigOptions(params: {
context: ResolvedGatewayCallContext;
env: NodeJS.ProcessEnv;
cfg: OpenClawConfig;
}) {
const { context, env, cfg } = params;
return {
cfg,
env,
explicitAuth: context.explicitAuth,
urlOverride: context.urlOverride,
urlOverrideSource: context.urlOverrideSource,
remotePasswordPrecedence: "env-first", // pragma: allowlist secret
});
}
modeOverride: context.modeOverride,
includeLegacyEnv: context.includeLegacyEnv,
localTokenPrecedence: context.localTokenPrecedence,
localPasswordPrecedence: context.localPasswordPrecedence,
remoteTokenPrecedence: context.remoteTokenPrecedence,
remotePasswordPrecedence: context.remotePasswordPrecedence ?? "env-first", // pragma: allowlist secret
remoteTokenFallback: context.remoteTokenFallback,
remotePasswordFallback: context.remotePasswordFallback,
} as const;
}
let resolvedConfig = context.config;
const envToken = readGatewayTokenEnv(env);
const envPassword = readGatewayPasswordEnv(env);
const defaults = context.config.secrets?.defaults;
const auth = context.config.gateway?.auth;
const remoteConfig = context.config.gateway?.remote;
const authMode = auth?.mode;
const localToken = trimToUndefined(auth?.token);
const remoteToken = trimToUndefined(remoteConfig?.token);
const remoteTokenConfigured = hasConfiguredSecretInput(remoteConfig?.token, defaults);
const tokenCanWin = Boolean(envToken || localToken || remoteToken || remoteTokenConfigured);
const remotePasswordConfigured =
context.isRemoteMode && hasConfiguredSecretInput(remoteConfig?.password, defaults);
const localPasswordRef = resolveSecretInputRef({ value: auth?.password, defaults }).ref;
const localPasswordCanWinInLocalMode =
authMode === "password" ||
(authMode !== "token" && authMode !== "none" && authMode !== "trusted-proxy" && !tokenCanWin);
const localTokenCanWinInLocalMode =
authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy";
const localPasswordCanWinInRemoteMode = !remotePasswordConfigured && !tokenCanWin;
const shouldResolveLocalPassword =
Boolean(auth) &&
!envPassword &&
Boolean(localPasswordRef) &&
(context.isRemoteMode ? localPasswordCanWinInRemoteMode : localPasswordCanWinInLocalMode);
if (shouldResolveLocalPassword) {
resolvedConfig = structuredClone(context.config);
const resolvedPassword = await resolveGatewaySecretInputString({
config: resolvedConfig,
value: resolvedConfig.gateway?.auth?.password,
path: "gateway.auth.password",
env,
});
if (resolvedConfig.gateway?.auth) {
resolvedConfig.gateway.auth.password = resolvedPassword;
}
}
const remote = context.isRemoteMode ? resolvedConfig.gateway?.remote : undefined;
const resolvedDefaults = resolvedConfig.secrets?.defaults;
if (remote) {
const localToken = trimToUndefined(resolvedConfig.gateway?.auth?.token);
const localPassword = trimToUndefined(resolvedConfig.gateway?.auth?.password);
const passwordCanWinBeforeRemoteTokenResolution = Boolean(
envPassword || localPassword || trimToUndefined(remote.password),
);
const remoteTokenRef = resolveSecretInputRef({
value: remote.token,
defaults: resolvedDefaults,
}).ref;
if (!passwordCanWinBeforeRemoteTokenResolution && !envToken && !localToken && remoteTokenRef) {
remote.token = await resolveGatewaySecretInputString({
config: resolvedConfig,
value: remote.token,
path: "gateway.remote.token",
env,
});
}
function isTokenGatewaySecretInputPath(path: SupportedGatewaySecretInputPath): boolean {
return path === "gateway.auth.token" || path === "gateway.remote.token";
}
const tokenCanWin = Boolean(envToken || localToken || trimToUndefined(remote.token));
const remotePasswordRef = resolveSecretInputRef({
value: remote.password,
defaults: resolvedDefaults,
}).ref;
if (!tokenCanWin && !envPassword && !localPassword && remotePasswordRef) {
remote.password = await resolveGatewaySecretInputString({
config: resolvedConfig,
value: remote.password,
path: "gateway.remote.password",
env,
function localAuthModeAllowsGatewaySecretInputPath(params: {
authMode: string | undefined;
path: SupportedGatewaySecretInputPath;
}): boolean {
const { authMode, path } = params;
if (authMode === "none" || authMode === "trusted-proxy") {
return false;
}
if (authMode === "token") {
return isTokenGatewaySecretInputPath(path);
}
if (authMode === "password") {
return !isTokenGatewaySecretInputPath(path);
}
return true;
}
function gatewaySecretInputPathCanWin(params: {
context: ResolvedGatewayCallContext;
env: NodeJS.ProcessEnv;
config: OpenClawConfig;
path: SupportedGatewaySecretInputPath;
}): boolean {
if (!hasConfiguredGatewaySecretRef(params.config, params.path)) {
return false;
}
const mode: GatewayCredentialMode =
params.context.modeOverride ?? (params.config.gateway?.mode === "remote" ? "remote" : "local");
if (
mode === "local" &&
!localAuthModeAllowsGatewaySecretInputPath({
authMode: params.config.gateway?.auth?.mode,
path: params.path,
})
) {
return false;
}
const sentinel = `__OPENCLAW_GATEWAY_SECRET_REF_PROBE_${params.path.replaceAll(".", "_")}__`;
const probeConfig = structuredClone(params.config);
for (const candidatePath of ALL_GATEWAY_SECRET_INPUT_PATHS) {
if (!hasConfiguredGatewaySecretRef(probeConfig, candidatePath)) {
continue;
}
assignResolvedGatewaySecretInput({
config: probeConfig,
path: candidatePath,
value: undefined,
});
}
}
const localModeRemote = !context.isRemoteMode ? resolvedConfig.gateway?.remote : undefined;
if (localModeRemote) {
const localToken = trimToUndefined(resolvedConfig.gateway?.auth?.token);
const localPassword = trimToUndefined(resolvedConfig.gateway?.auth?.password);
const localModePasswordSourceConfigured = Boolean(
envPassword || localPassword || trimToUndefined(localModeRemote.password),
assignResolvedGatewaySecretInput({
config: probeConfig,
path: params.path,
value: sentinel,
});
try {
const resolved = resolveGatewayCredentialsFromConfig(
resolveGatewayCredentialsFromConfigOptions({
context: params.context,
env: params.env,
cfg: probeConfig,
}),
);
const passwordCanWinBeforeRemoteTokenResolution =
localPasswordCanWinInLocalMode && localModePasswordSourceConfigured;
const remoteTokenRef = resolveSecretInputRef({
value: localModeRemote.token,
defaults: resolvedDefaults,
}).ref;
if (
localTokenCanWinInLocalMode &&
!passwordCanWinBeforeRemoteTokenResolution &&
!envToken &&
!localToken &&
remoteTokenRef
) {
localModeRemote.token = await resolveGatewaySecretInputString({
config: resolvedConfig,
value: localModeRemote.token,
path: "gateway.remote.token",
const tokenCanWin = resolved.token === sentinel && !resolved.password;
const passwordCanWin = resolved.password === sentinel && !resolved.token;
return tokenCanWin || passwordCanWin;
} catch {
return false;
}
}
async function resolveConfiguredGatewaySecretInput(params: {
config: OpenClawConfig;
path: SupportedGatewaySecretInputPath;
env: NodeJS.ProcessEnv;
}): Promise<string | undefined> {
const { config, path, env } = params;
if (path === "gateway.auth.token") {
return resolveGatewaySecretInputString({
config,
value: config.gateway?.auth?.token,
path,
env,
});
}
const tokenCanWin = Boolean(envToken || localToken || trimToUndefined(localModeRemote.token));
const remotePasswordRef = resolveSecretInputRef({
value: localModeRemote.password,
defaults: resolvedDefaults,
}).ref;
if (
!tokenCanWin &&
!envPassword &&
!localPassword &&
remotePasswordRef &&
localPasswordCanWinInLocalMode
) {
localModeRemote.password = await resolveGatewaySecretInputString({
config: resolvedConfig,
value: localModeRemote.password,
path: "gateway.remote.password",
if (path === "gateway.auth.password") {
return resolveGatewaySecretInputString({
config,
value: config.gateway?.auth?.password,
path,
env,
});
}
if (path === "gateway.remote.token") {
return resolveGatewaySecretInputString({
config,
value: config.gateway?.remote?.token,
path,
env,
});
}
return resolveGatewayCredentialsFromConfig({
return resolveGatewaySecretInputString({
config,
value: config.gateway?.remote?.password,
path,
env,
});
}
function assignResolvedGatewaySecretInput(params: {
config: OpenClawConfig;
path: SupportedGatewaySecretInputPath;
value: string | undefined;
}): void {
const { config, path, value } = params;
if (path === "gateway.auth.token") {
if (config.gateway?.auth) {
config.gateway.auth.token = value;
}
return;
}
if (path === "gateway.auth.password") {
if (config.gateway?.auth) {
config.gateway.auth.password = value;
}
return;
}
if (path === "gateway.remote.token") {
if (config.gateway?.remote) {
config.gateway.remote.token = value;
}
return;
}
if (config.gateway?.remote) {
config.gateway.remote.password = value;
}
}
async function resolvePreferredGatewaySecretInputs(params: {
context: ResolvedGatewayCallContext;
env: NodeJS.ProcessEnv;
config: OpenClawConfig;
}): Promise<OpenClawConfig> {
let nextConfig = params.config;
for (const path of ALL_GATEWAY_SECRET_INPUT_PATHS) {
if (
!gatewaySecretInputPathCanWin({
context: params.context,
env: params.env,
config: nextConfig,
path,
})
) {
continue;
}
if (nextConfig === params.config) {
nextConfig = structuredClone(params.config);
}
try {
const resolvedValue = await resolveConfiguredGatewaySecretInput({
config: nextConfig,
path,
env: params.env,
});
assignResolvedGatewaySecretInput({
config: nextConfig,
path,
value: resolvedValue,
});
} catch {
// Keep scanning candidate paths so unresolved higher-priority refs do not
// prevent valid fallback refs from being considered.
continue;
}
}
return nextConfig;
}
async function resolveGatewayCredentialsFromConfigWithSecretInputs(params: {
context: ResolvedGatewayCallContext;
env: NodeJS.ProcessEnv;
}): Promise<{ token?: string; password?: string }> {
let resolvedConfig = await resolvePreferredGatewaySecretInputs({
context: params.context,
env: params.env,
config: params.context.config,
});
const resolvedPaths = new Set<SupportedGatewaySecretInputPath>();
for (;;) {
try {
return resolveGatewayCredentialsFromConfig(
resolveGatewayCredentialsFromConfigOptions({
context: params.context,
env: params.env,
cfg: resolvedConfig,
env,
explicitAuth: context.explicitAuth,
urlOverride: context.urlOverride,
urlOverrideSource: context.urlOverrideSource,
remotePasswordPrecedence: "env-first", // pragma: allowlist secret
}),
);
} catch (error) {
if (!(error instanceof GatewaySecretRefUnavailableError)) {
throw error;
}
const path = error.path;
if (!isSupportedGatewaySecretInputPath(path) || resolvedPaths.has(path)) {
throw error;
}
if (resolvedConfig === params.context.config) {
resolvedConfig = structuredClone(params.context.config);
}
const resolvedValue = await resolveConfiguredGatewaySecretInput({
config: resolvedConfig,
path,
env: params.env,
});
assignResolvedGatewaySecretInput({
config: resolvedConfig,
path,
value: resolvedValue,
});
resolvedPaths.add(path);
}
}
}
export async function resolveGatewayCredentialsWithSecretInputs(params: {
config: OpenClawConfig;
explicitAuth?: ExplicitGatewayAuth;
urlOverride?: string;
urlOverrideSource?: "cli" | "env";
env?: NodeJS.ProcessEnv;
modeOverride?: GatewayCredentialMode;
includeLegacyEnv?: boolean;
localTokenPrecedence?: GatewayCredentialPrecedence;
localPasswordPrecedence?: GatewayCredentialPrecedence;
remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence;
remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence;
remoteTokenFallback?: GatewayRemoteCredentialFallback;
remotePasswordFallback?: GatewayRemoteCredentialFallback;
}): Promise<{ token?: string; password?: string }> {
const modeOverride = params.modeOverride;
const isRemoteMode = modeOverride
? modeOverride === "remote"
: params.config.gateway?.mode === "remote";
const remoteFromConfig =
params.config.gateway?.mode === "remote"
? (params.config.gateway?.remote as GatewayRemoteSettings | undefined)
: undefined;
const remoteFromOverride =
modeOverride === "remote"
? (params.config.gateway?.remote as GatewayRemoteSettings | undefined)
: undefined;
const context: ResolvedGatewayCallContext = {
config: params.config,
configPath: resolveConfigPath(process.env, resolveStateDir(process.env)),
isRemoteMode: params.config.gateway?.mode === "remote",
remote:
params.config.gateway?.mode === "remote"
? (params.config.gateway?.remote as GatewayRemoteSettings | undefined)
: undefined,
isRemoteMode,
remote: remoteFromOverride ?? remoteFromConfig,
urlOverride: trimToUndefined(params.urlOverride),
remoteUrl:
params.config.gateway?.mode === "remote"
urlOverrideSource: params.urlOverrideSource,
remoteUrl: isRemoteMode
? trimToUndefined((params.config.gateway?.remote as GatewayRemoteSettings | undefined)?.url)
: undefined,
explicitAuth: resolveExplicitGatewayAuth(params.explicitAuth),
modeOverride,
includeLegacyEnv: params.includeLegacyEnv,
localTokenPrecedence: params.localTokenPrecedence,
localPasswordPrecedence: params.localPasswordPrecedence,
remoteTokenPrecedence: params.remoteTokenPrecedence,
remotePasswordPrecedence: params.remotePasswordPrecedence,
remoteTokenFallback: params.remoteTokenFallback,
remotePasswordFallback: params.remotePasswordFallback,
};
return resolveGatewayCredentialsWithEnv(context, params.env ?? process.env);
}

View File

@ -0,0 +1,59 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
const GATEWAY_CLIENT_CONSTRUCTOR_PATTERN = /new\s+GatewayClient\s*\(/;
const ALLOWED_GATEWAY_CLIENT_CALLSITES = new Set([
"src/acp/server.ts",
"src/discord/monitor/exec-approvals.ts",
"src/gateway/call.ts",
"src/gateway/probe.ts",
"src/node-host/runner.ts",
"src/tui/gateway-chat.ts",
]);
async function collectSourceFiles(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectSourceFiles(fullPath)));
continue;
}
if (!entry.isFile()) {
continue;
}
if (!entry.name.endsWith(".ts")) {
continue;
}
if (
entry.name.endsWith(".test.ts") ||
entry.name.endsWith(".e2e.ts") ||
entry.name.endsWith(".e2e.test.ts") ||
entry.name.endsWith(".live.test.ts")
) {
continue;
}
files.push(fullPath);
}
return files;
}
describe("GatewayClient production callsites", () => {
it("remain constrained to allowlisted files", async () => {
const root = process.cwd();
const sourceFiles = await collectSourceFiles(path.join(root, "src"));
const callsites: string[] = [];
for (const fullPath of sourceFiles) {
const relativePath = path.relative(root, fullPath).replaceAll(path.sep, "/");
const content = await fs.readFile(fullPath, "utf8");
if (GATEWAY_CLIENT_CONSTRUCTOR_PATTERN.test(content)) {
callsites.push(relativePath);
}
}
const expected = [...ALLOWED_GATEWAY_CLIENT_CALLSITES].toSorted();
expect(callsites.toSorted()).toEqual(expected);
});
});

View File

@ -0,0 +1,346 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveGatewayConnectionAuth,
resolveGatewayConnectionAuthFromConfig,
type GatewayConnectionAuthOptions,
} from "./connection-auth.js";
type ResolvedAuth = { token?: string; password?: string };
type ConnectionAuthCase = {
name: string;
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
options?: Partial<Omit<GatewayConnectionAuthOptions, "config" | "env">>;
expected: ResolvedAuth;
};
function cfg(input: Partial<OpenClawConfig>): OpenClawConfig {
return input as OpenClawConfig;
}
const DEFAULT_ENV = {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret
} as NodeJS.ProcessEnv;
describe("resolveGatewayConnectionAuth", () => {
const cases: ConnectionAuthCase[] = [
{
name: "local mode defaults to env-first token/password",
cfg: cfg({
gateway: {
mode: "local",
auth: {
token: "config-token",
password: "config-password", // pragma: allowlist secret
},
remote: {
token: "remote-token",
password: "remote-password", // pragma: allowlist secret
},
},
}),
env: DEFAULT_ENV,
expected: {
token: "env-token",
password: "env-password", // pragma: allowlist secret
},
},
{
name: "local mode supports config-first token/password",
cfg: cfg({
gateway: {
mode: "local",
auth: {
token: "config-token",
password: "config-password", // pragma: allowlist secret
},
},
}),
env: DEFAULT_ENV,
options: {
localTokenPrecedence: "config-first",
localPasswordPrecedence: "config-first",
},
expected: {
token: "config-token",
password: "config-password", // pragma: allowlist secret
},
},
{
name: "local mode precedence can mix env-first token with config-first password",
cfg: cfg({
gateway: {
mode: "local",
auth: {},
remote: {
token: "remote-token",
password: "remote-password", // pragma: allowlist secret
},
},
}),
env: DEFAULT_ENV,
options: {
localTokenPrecedence: "env-first",
localPasswordPrecedence: "config-first",
},
expected: {
token: "env-token",
password: "remote-password", // pragma: allowlist secret
},
},
{
name: "remote mode defaults to remote-first token and env-first password",
cfg: cfg({
gateway: {
mode: "remote",
auth: {
token: "local-token",
password: "local-password", // pragma: allowlist secret
},
remote: {
url: "wss://remote.example",
token: "remote-token",
password: "remote-password", // pragma: allowlist secret
},
},
}),
env: DEFAULT_ENV,
expected: {
token: "remote-token",
password: "env-password", // pragma: allowlist secret
},
},
{
name: "remote mode supports env-first token with remote-first password",
cfg: cfg({
gateway: {
mode: "remote",
auth: {
token: "local-token",
password: "local-password", // pragma: allowlist secret
},
remote: {
url: "wss://remote.example",
token: "remote-token",
password: "remote-password", // pragma: allowlist secret
},
},
}),
env: DEFAULT_ENV,
options: {
remoteTokenPrecedence: "env-first",
remotePasswordPrecedence: "remote-first",
},
expected: {
token: "env-token",
password: "remote-password", // pragma: allowlist secret
},
},
{
name: "remote-only fallback can suppress env/local password fallback",
cfg: cfg({
gateway: {
mode: "remote",
auth: {
token: "local-token",
password: "local-password", // pragma: allowlist secret
},
remote: {
url: "wss://remote.example",
token: "remote-token",
},
},
}),
env: DEFAULT_ENV,
options: {
remoteTokenFallback: "remote-only",
remotePasswordFallback: "remote-only",
},
expected: {
token: "remote-token",
password: undefined,
},
},
{
name: "modeOverride can force remote precedence while config gateway.mode is local",
cfg: cfg({
gateway: {
mode: "local",
auth: {
token: "local-token",
password: "local-password", // pragma: allowlist secret
},
remote: {
url: "wss://remote.example",
token: "remote-token",
password: "remote-password", // pragma: allowlist secret
},
},
}),
env: DEFAULT_ENV,
options: {
modeOverride: "remote",
remoteTokenPrecedence: "remote-first",
remotePasswordPrecedence: "remote-first",
},
expected: {
token: "remote-token",
password: "remote-password", // pragma: allowlist secret
},
},
{
name: "includeLegacyEnv controls CLAWDBOT fallback",
cfg: cfg({
gateway: {
mode: "local",
auth: {},
},
}),
env: {
CLAWDBOT_GATEWAY_TOKEN: "legacy-token",
CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", // pragma: allowlist secret
} as NodeJS.ProcessEnv,
options: {
includeLegacyEnv: true,
},
expected: {
token: "legacy-token",
password: "legacy-password", // pragma: allowlist secret
},
},
];
it.each(cases)("$name", async ({ cfg, env, options, expected }) => {
const asyncResolved = await resolveGatewayConnectionAuth({
config: cfg,
env,
...options,
});
const syncResolved = resolveGatewayConnectionAuthFromConfig({
cfg,
env,
...options,
});
expect(asyncResolved).toEqual(expected);
expect(syncResolved).toEqual(expected);
});
it("can disable legacy env fallback", async () => {
const config = cfg({
gateway: {
mode: "local",
auth: {},
},
});
const env = {
CLAWDBOT_GATEWAY_TOKEN: "legacy-token",
CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", // pragma: allowlist secret
} as NodeJS.ProcessEnv;
const resolved = await resolveGatewayConnectionAuth({
config,
env,
includeLegacyEnv: false,
});
expect(resolved).toEqual({
token: undefined,
password: undefined,
});
});
it("resolves local SecretRef token when legacy env is disabled", async () => {
const config = cfg({
gateway: {
mode: "local",
auth: {
token: { source: "env", provider: "default", id: "LOCAL_SECRET_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
});
const env = {
CLAWDBOT_GATEWAY_TOKEN: "legacy-token",
LOCAL_SECRET_TOKEN: "resolved-from-secretref",
} as NodeJS.ProcessEnv;
const resolved = await resolveGatewayConnectionAuth({
config,
env,
includeLegacyEnv: false,
});
expect(resolved).toEqual({
token: "resolved-from-secretref",
password: undefined,
});
});
it("resolves config-first token SecretRef even when OPENCLAW env token exists", async () => {
const config = cfg({
gateway: {
mode: "local",
auth: {
token: { source: "env", provider: "default", id: "CONFIG_FIRST_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
});
const env = {
OPENCLAW_GATEWAY_TOKEN: "env-token",
CONFIG_FIRST_TOKEN: "config-first-token",
} as NodeJS.ProcessEnv;
const resolved = await resolveGatewayConnectionAuth({
config,
env,
includeLegacyEnv: false,
localTokenPrecedence: "config-first",
});
expect(resolved).toEqual({
token: "config-first-token",
password: undefined,
});
});
it("resolves config-first password SecretRef even when OPENCLAW env password exists", async () => {
const config = cfg({
gateway: {
mode: "local",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "CONFIG_FIRST_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
});
const env = {
OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret
CONFIG_FIRST_PASSWORD: "config-first-password", // pragma: allowlist secret
} as NodeJS.ProcessEnv;
const resolved = await resolveGatewayConnectionAuth({
config,
env,
includeLegacyEnv: false,
localPasswordPrecedence: "config-first",
});
expect(resolved).toEqual({
token: undefined,
password: "config-first-password", // pragma: allowlist secret
});
});
});

View File

@ -0,0 +1,66 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ExplicitGatewayAuth } from "./call.js";
import { resolveGatewayCredentialsWithSecretInputs } from "./call.js";
import type {
GatewayCredentialMode,
GatewayCredentialPrecedence,
GatewayRemoteCredentialFallback,
GatewayRemoteCredentialPrecedence,
} from "./credentials.js";
import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
export type GatewayConnectionAuthOptions = {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
explicitAuth?: ExplicitGatewayAuth;
urlOverride?: string;
urlOverrideSource?: "cli" | "env";
modeOverride?: GatewayCredentialMode;
includeLegacyEnv?: boolean;
localTokenPrecedence?: GatewayCredentialPrecedence;
localPasswordPrecedence?: GatewayCredentialPrecedence;
remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence;
remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence;
remoteTokenFallback?: GatewayRemoteCredentialFallback;
remotePasswordFallback?: GatewayRemoteCredentialFallback;
};
export async function resolveGatewayConnectionAuth(
params: GatewayConnectionAuthOptions,
): Promise<{ token?: string; password?: string }> {
return await resolveGatewayCredentialsWithSecretInputs({
config: params.config,
env: params.env,
explicitAuth: params.explicitAuth,
urlOverride: params.urlOverride,
urlOverrideSource: params.urlOverrideSource,
modeOverride: params.modeOverride,
includeLegacyEnv: params.includeLegacyEnv,
localTokenPrecedence: params.localTokenPrecedence,
localPasswordPrecedence: params.localPasswordPrecedence,
remoteTokenPrecedence: params.remoteTokenPrecedence,
remotePasswordPrecedence: params.remotePasswordPrecedence,
remoteTokenFallback: params.remoteTokenFallback,
remotePasswordFallback: params.remotePasswordFallback,
});
}
export function resolveGatewayConnectionAuthFromConfig(
params: Omit<GatewayConnectionAuthOptions, "config"> & { cfg: OpenClawConfig },
): { token?: string; password?: string } {
return resolveGatewayCredentialsFromConfig({
cfg: params.cfg,
env: params.env,
explicitAuth: params.explicitAuth,
urlOverride: params.urlOverride,
urlOverrideSource: params.urlOverrideSource,
modeOverride: params.modeOverride,
includeLegacyEnv: params.includeLegacyEnv,
localTokenPrecedence: params.localTokenPrecedence,
localPasswordPrecedence: params.localPasswordPrecedence,
remoteTokenPrecedence: params.remoteTokenPrecedence,
remotePasswordPrecedence: params.remotePasswordPrecedence,
remoteTokenFallback: params.remoteTokenFallback,
remotePasswordFallback: params.remotePasswordFallback,
});
}

View File

@ -24,6 +24,27 @@ type HardeningCase = {
checkRawCommandMatchesArgv?: boolean;
};
function createScriptOperandFixture(tmp: string): {
command: string[];
scriptPath: string;
initialBody: string;
} {
if (process.platform === "win32") {
const scriptPath = path.join(tmp, "run.js");
return {
command: [process.execPath, "./run.js"],
scriptPath,
initialBody: 'console.log("SAFE");\n',
};
}
const scriptPath = path.join(tmp, "run.sh");
return {
command: ["/bin/sh", "./run.sh"],
scriptPath,
initialBody: "#!/bin/sh\necho SAFE\n",
};
}
describe("hardenApprovedExecutionPaths", () => {
const cases: HardeningCase[] = [
{
@ -131,12 +152,14 @@ describe("hardenApprovedExecutionPaths", () => {
it("captures mutable shell script operands in approval plans", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-plan-"));
const script = path.join(tmp, "run.sh");
fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n");
fs.chmodSync(script, 0o755);
const fixture = createScriptOperandFixture(tmp);
fs.writeFileSync(fixture.scriptPath, fixture.initialBody);
if (process.platform !== "win32") {
fs.chmodSync(fixture.scriptPath, 0o755);
}
try {
const prepared = buildSystemRunApprovalPlan({
command: ["/bin/sh", "./run.sh"],
command: fixture.command,
cwd: tmp,
});
expect(prepared.ok).toBe(true);
@ -145,7 +168,7 @@ describe("hardenApprovedExecutionPaths", () => {
}
expect(prepared.plan.mutableFileOperand).toEqual({
argvIndex: 1,
path: fs.realpathSync(script),
path: fs.realpathSync(fixture.scriptPath),
sha256: expect.any(String),
});
} finally {

View File

@ -85,6 +85,30 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
});
}
function createMutableScriptOperandFixture(tmp: string): {
command: string[];
scriptPath: string;
initialBody: string;
changedBody: string;
} {
if (process.platform === "win32") {
const scriptPath = path.join(tmp, "run.js");
return {
command: [process.execPath, "./run.js"],
scriptPath,
initialBody: 'console.log("SAFE");\n',
changedBody: 'console.log("PWNED");\n',
};
}
const scriptPath = path.join(tmp, "run.sh");
return {
command: ["/bin/sh", "./run.sh"],
scriptPath,
initialBody: "#!/bin/sh\necho SAFE\n",
changedBody: "#!/bin/sh\necho PWNED\n",
};
}
function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] {
return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload];
}
@ -692,12 +716,14 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
it("denies approval-based execution when a script operand changes after approval", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-drift-"));
const script = path.join(tmp, "run.sh");
fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n");
fs.chmodSync(script, 0o755);
const fixture = createMutableScriptOperandFixture(tmp);
fs.writeFileSync(fixture.scriptPath, fixture.initialBody);
if (process.platform !== "win32") {
fs.chmodSync(fixture.scriptPath, 0o755);
}
try {
const prepared = buildSystemRunApprovalPlan({
command: ["/bin/sh", "./run.sh"],
command: fixture.command,
cwd: tmp,
});
expect(prepared.ok).toBe(true);
@ -705,7 +731,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
throw new Error("unreachable");
}
fs.writeFileSync(script, "#!/bin/sh\necho PWNED\n");
fs.writeFileSync(fixture.scriptPath, fixture.changedBody);
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
command: prepared.plan.argv,
@ -729,12 +755,14 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
it("keeps approved shell script execution working when the script is unchanged", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-stable-"));
const script = path.join(tmp, "run.sh");
fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n");
fs.chmodSync(script, 0o755);
const fixture = createMutableScriptOperandFixture(tmp);
fs.writeFileSync(fixture.scriptPath, fixture.initialBody);
if (process.platform !== "win32") {
fs.chmodSync(fixture.scriptPath, 0o755);
}
try {
const prepared = buildSystemRunApprovalPlan({
command: ["/bin/sh", "./run.sh"],
command: fixture.command,
cwd: tmp,
});
expect(prepared.ok).toBe(true);

View File

@ -20,6 +20,56 @@ function createRemoteGatewayTokenRefConfig(tokenId: string): OpenClawConfig {
}
describe("resolveNodeHostGatewayCredentials", () => {
it("does not inherit gateway.remote token in local mode", async () => {
const config = {
gateway: {
mode: "local",
remote: { token: "remote-only-token" },
},
} as OpenClawConfig;
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
},
async () => {
const credentials = await resolveNodeHostGatewayCredentials({ config });
expect(credentials.token).toBeUndefined();
expect(credentials.password).toBeUndefined();
},
);
});
it("ignores unresolved gateway.remote token refs in local mode", async () => {
const config = {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
remote: {
token: { source: "env", provider: "default", id: "MISSING_REMOTE_GATEWAY_TOKEN" },
},
},
} as OpenClawConfig;
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
MISSING_REMOTE_GATEWAY_TOKEN: undefined,
},
async () => {
const credentials = await resolveNodeHostGatewayCredentials({ config });
expect(credentials.token).toBeUndefined();
expect(credentials.password).toBeUndefined();
},
);
});
it("resolves remote token SecretRef values", async () => {
const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN");

View File

@ -1,7 +1,7 @@
import { resolveBrowserConfig } from "../browser/config.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { normalizeSecretInputString } from "../config/types.secrets.js";
import { GatewayClient } from "../gateway/client.js";
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
@ -12,7 +12,6 @@ import {
NODE_SYSTEM_RUN_COMMANDS,
} from "../infra/node-commands.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { VERSION } from "../version.js";
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
@ -110,73 +109,36 @@ function ensureNodePathEnv(): string {
return DEFAULT_NODE_PATH;
}
async function resolveNodeHostSecretInputString(params: {
config: OpenClawConfig;
value: unknown;
path: string;
env: NodeJS.ProcessEnv;
}): Promise<string | undefined> {
const resolvedValue = await resolveSecretInputString({
config: params.config,
value: params.value,
env: params.env,
onResolveRefError: (error) => {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, {
cause: error,
});
},
});
if (!resolvedValue) {
throw new Error(`${params.path} resolved to an empty or non-string value.`);
}
return resolvedValue;
}
export async function resolveNodeHostGatewayCredentials(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<{ token?: string; password?: string }> {
const env = params.env ?? process.env;
const isRemoteMode = params.config.gateway?.mode === "remote";
const authMode = params.config.gateway?.auth?.mode;
const tokenPath = isRemoteMode ? "gateway.remote.token" : "gateway.auth.token";
const passwordPath = isRemoteMode ? "gateway.remote.password" : "gateway.auth.password";
const configuredToken = isRemoteMode
? params.config.gateway?.remote?.token
: params.config.gateway?.auth?.token;
const configuredPassword = isRemoteMode
? params.config.gateway?.remote?.password
: params.config.gateway?.auth?.password;
const mode = params.config.gateway?.mode === "remote" ? "remote" : "local";
const configForResolution =
mode === "local" ? buildNodeHostLocalAuthConfig(params.config) : params.config;
return await resolveGatewayConnectionAuth({
config: configForResolution,
env: params.env,
includeLegacyEnv: false,
localTokenPrecedence: "env-first",
localPasswordPrecedence: "env-first",
remoteTokenPrecedence: "env-first",
remotePasswordPrecedence: "env-first",
});
}
const token =
normalizeSecretInputString(env.OPENCLAW_GATEWAY_TOKEN) ??
(await resolveNodeHostSecretInputString({
config: params.config,
value: configuredToken,
path: tokenPath,
env,
}));
const tokenCanWin = Boolean(token);
const localPasswordCanWin =
authMode === "password" ||
(authMode !== "token" && authMode !== "none" && authMode !== "trusted-proxy" && !tokenCanWin);
const shouldResolveConfiguredPassword =
!normalizeSecretInputString(env.OPENCLAW_GATEWAY_PASSWORD) &&
!tokenCanWin &&
(isRemoteMode || localPasswordCanWin);
const password =
normalizeSecretInputString(env.OPENCLAW_GATEWAY_PASSWORD) ??
(shouldResolveConfiguredPassword
? await resolveNodeHostSecretInputString({
config: params.config,
value: configuredPassword,
path: passwordPath,
env,
})
: normalizeSecretInputString(configuredPassword));
return { token, password };
function buildNodeHostLocalAuthConfig(config: OpenClawConfig): OpenClawConfig {
if (!config.gateway?.remote?.token && !config.gateway?.remote?.password) {
return config;
}
const nextConfig = structuredClone(config);
if (nextConfig.gateway?.remote) {
// Local node-host must not inherit gateway.remote.* auth material, which can
// suppress GatewayClient device-token fallback and cause local token mismatches.
nextConfig.gateway.remote.token = undefined;
nextConfig.gateway.remote.password = undefined;
}
return nextConfig;
}
export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {