mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 02:28:27 +00:00
gateway: harden shared auth resolution across systemd, discord, and node host
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
59
src/gateway/client-callsites.guard.test.ts
Normal file
59
src/gateway/client-callsites.guard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
346
src/gateway/connection-auth.test.ts
Normal file
346
src/gateway/connection-auth.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
66
src/gateway/connection-auth.ts
Normal file
66
src/gateway/connection-auth.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user