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
28 changed files with 1498 additions and 255 deletions

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) => ({
token: undefined,
password: undefined,
}),
),
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: () => ({
url: "ws://127.0.0.1:18789",
}),
resolveGatewayCredentialsWithSecretInputs: (params: unknown) =>
mockState.resolveGatewayCredentialsWithSecretInputs(params),
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",
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,
env,
explicitAuth: context.explicitAuth,
urlOverride: context.urlOverride,
urlOverrideSource: context.urlOverrideSource,
remotePasswordPrecedence: "env-first", // pragma: allowlist secret
});
}
return resolveGatewayCredentialsFromConfigWithSecretInputs({ context, env });
}
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,
});
}
type SupportedGatewaySecretInputPath =
| "gateway.auth.token"
| "gateway.auth.password"
| "gateway.remote.token"
| "gateway.remote.password";
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,
});
}
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;
}
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),
);
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",
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",
env,
});
}
if (path === "gateway.auth.password") {
return config.gateway?.auth?.password;
}
return resolveGatewayCredentialsFromConfig({
cfg: resolvedConfig,
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;
}
function isTokenGatewaySecretInputPath(path: SupportedGatewaySecretInputPath): boolean {
return path === "gateway.auth.token" || path === "gateway.remote.token";
}
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,
});
}
assignResolvedGatewaySecretInput({
config: probeConfig,
path: params.path,
value: sentinel,
});
try {
const resolved = resolveGatewayCredentialsFromConfig(
resolveGatewayCredentialsFromConfigOptions({
context: params.context,
env: params.env,
cfg: probeConfig,
}),
);
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,
});
}
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 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,
}),
);
} 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"
? trimToUndefined((params.config.gateway?.remote as GatewayRemoteSettings | undefined)?.url)
: undefined,
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> {