refactor: share gateway auth and approval helpers

This commit is contained in:
Peter Steinberger
2026-04-06 07:40:16 +01:00
parent 1d8d2ddaa1
commit bb01e49192
31 changed files with 1768 additions and 1347 deletions

View File

@@ -10,16 +10,24 @@ type GatewayClientAuth = {
token?: string;
password?: string;
};
type ResolveGatewayConnectionAuth = (params: unknown) => Promise<GatewayClientAuth>;
type ResolveGatewayClientBootstrap = (params: unknown) => Promise<{
url: string;
urlSource: string;
auth: GatewayClientAuth;
}>;
const mockState = vi.hoisted(() => ({
gateways: [] as MockGatewayClient[],
gatewayAuth: [] as GatewayClientAuth[],
agentSideConnectionCtor: vi.fn(),
agentStart: vi.fn(),
resolveGatewayConnectionAuth: vi.fn<ResolveGatewayConnectionAuth>(async (_params) => ({
token: undefined,
password: undefined,
resolveGatewayClientBootstrap: vi.fn<ResolveGatewayClientBootstrap>(async (_params) => ({
url: "ws://127.0.0.1:18789",
urlSource: "local loopback",
auth: {
token: undefined,
password: undefined,
},
})),
}));
@@ -82,8 +90,9 @@ vi.mock("../gateway/call.js", () => ({
},
}));
vi.mock("../gateway/connection-auth.js", () => ({
resolveGatewayConnectionAuth: (params: unknown) => mockState.resolveGatewayConnectionAuth(params),
vi.mock("../gateway/client-bootstrap.js", () => ({
resolveGatewayClientBootstrap: (params: unknown) =>
mockState.resolveGatewayClientBootstrap(params),
}));
vi.mock("../gateway/client.js", () => ({
@@ -156,10 +165,14 @@ describe("serveAcpGateway startup", () => {
mockState.gatewayAuth.length = 0;
mockState.agentSideConnectionCtor.mockReset();
mockState.agentStart.mockReset();
mockState.resolveGatewayConnectionAuth.mockReset();
mockState.resolveGatewayConnectionAuth.mockResolvedValue({
token: undefined,
password: undefined,
mockState.resolveGatewayClientBootstrap.mockReset();
mockState.resolveGatewayClientBootstrap.mockResolvedValue({
url: "ws://127.0.0.1:18789",
urlSource: "local loopback",
auth: {
token: undefined,
password: undefined,
},
});
});
@@ -199,9 +212,13 @@ describe("serveAcpGateway startup", () => {
});
it("passes resolved SecretInput gateway credentials to the ACP gateway client", async () => {
mockState.resolveGatewayConnectionAuth.mockResolvedValue({
token: undefined,
password: "resolved-secret-password", // pragma: allowlist secret
mockState.resolveGatewayClientBootstrap.mockResolvedValue({
url: "ws://127.0.0.1:18789",
urlSource: "local loopback",
auth: {
token: undefined,
password: "resolved-secret-password", // pragma: allowlist secret
},
});
const { signalHandlers, onceSpy } = captureProcessSignalHandlers();
@@ -209,7 +226,7 @@ describe("serveAcpGateway startup", () => {
const servePromise = serveAcpGateway({});
await Promise.resolve();
expect(mockState.resolveGatewayConnectionAuth).toHaveBeenCalledWith(
expect(mockState.resolveGatewayClientBootstrap).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
}),
@@ -235,11 +252,10 @@ describe("serveAcpGateway startup", () => {
});
await Promise.resolve();
expect(mockState.resolveGatewayConnectionAuth).toHaveBeenCalledWith(
expect(mockState.resolveGatewayClientBootstrap).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
urlOverride: "wss://override.example/ws",
urlOverrideSource: "cli",
gatewayUrl: "wss://override.example/ws",
}),
);

View File

@@ -3,9 +3,8 @@ 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 } from "../gateway/call.js";
import { resolveGatewayClientBootstrap } from "../gateway/client-bootstrap.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";
@@ -14,25 +13,14 @@ import { normalizeAcpProvenanceMode, type AcpServerOptions } from "./types.js";
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
const cfg = loadConfig();
const connection = buildGatewayConnectionDetails({
config: cfg,
url: opts.gatewayUrl,
});
const gatewayUrlOverrideSource =
connection.urlSource === "cli --url"
? "cli"
: connection.urlSource === "env OPENCLAW_GATEWAY_URL"
? "env"
: undefined;
const creds = await resolveGatewayConnectionAuth({
const bootstrap = await resolveGatewayClientBootstrap({
config: cfg,
gatewayUrl: opts.gatewayUrl,
explicitAuth: {
token: opts.gatewayToken,
password: opts.gatewayPassword,
},
env: process.env,
urlOverride: gatewayUrlOverrideSource ? connection.url : undefined,
urlOverrideSource: gatewayUrlOverrideSource,
});
let agent: AcpGatewayAgent | null = null;
@@ -64,9 +52,9 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void
};
const gateway = new GatewayClient({
url: connection.url,
token: creds.token,
password: creds.password,
url: bootstrap.url,
token: bootstrap.auth.token,
password: bootstrap.auth.password,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "ACP",
clientVersion: "acp",

View File

@@ -15,7 +15,9 @@ const serviceRestart = vi.fn().mockResolvedValue({ outcome: "completed" });
const serviceIsLoaded = vi.fn().mockResolvedValue(false);
const serviceReadCommand = vi.fn().mockResolvedValue(null);
const serviceReadRuntime = vi.fn().mockResolvedValue({ status: "running" });
const resolveGatewayProbeAuthWithSecretInputs = vi.fn(async (_opts?: unknown) => ({}));
const resolveGatewayProbeAuthSafeWithSecretInputs = vi.fn(async (_opts?: unknown) => ({
auth: {},
}));
const findExtraGatewayServices = vi.fn(async (_env: unknown, _opts?: unknown) => []);
const inspectPortUsage = vi.fn(async (port: number) => ({
port,
@@ -62,8 +64,8 @@ vi.mock("./daemon-cli/probe.js", () => ({
}));
vi.mock("../gateway/probe-auth.js", () => ({
resolveGatewayProbeAuthWithSecretInputs: (opts: unknown) =>
resolveGatewayProbeAuthWithSecretInputs(opts),
resolveGatewayProbeAuthSafeWithSecretInputs: (opts: unknown) =>
resolveGatewayProbeAuthSafeWithSecretInputs(opts),
}));
vi.mock("../daemon/program-args.js", () => ({
@@ -157,7 +159,8 @@ describe("daemon-cli coverage", () => {
delete process.env.OPENCLAW_GATEWAY_PORT;
delete process.env.OPENCLAW_PROFILE;
serviceReadCommand.mockResolvedValue(null);
resolveGatewayProbeAuthWithSecretInputs.mockClear();
resolveGatewayProbeAuthSafeWithSecretInputs.mockClear();
findExtraGatewayServices.mockClear();
buildGatewayInstallPlan.mockClear();
});
@@ -175,7 +178,7 @@ describe("daemon-cli coverage", () => {
expect(probeGatewayStatus).toHaveBeenCalledWith(
expect.objectContaining({ url: "ws://127.0.0.1:18789" }),
);
expect(findExtraGatewayServices).toHaveBeenCalled();
expect(findExtraGatewayServices).not.toHaveBeenCalled();
expect(inspectPortUsage).toHaveBeenCalled();
});

View File

@@ -1,8 +1,7 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveSecretInputRef } from "../../config/types.secrets.js";
import { createGatewayCredentialPlan, trimToUndefined } from "../../gateway/credential-planner.js";
import { resolveGatewayAuthToken } from "../../gateway/auth-token-resolution.js";
import { createGatewayCredentialPlan } from "../../gateway/credential-planner.js";
import { GatewaySecretRefUnavailableError } from "../../gateway/credentials.js";
import { resolveConfiguredSecretInputString } from "../../gateway/resolve-configured-secret-input-string.js";
function authModeDisablesToken(mode: string | undefined): boolean {
return mode === "password" || mode === "none" || mode === "trusted-proxy";
@@ -35,24 +34,17 @@ export async function resolveGatewayTokenForDriftCheck(params: {
return undefined;
}
const tokenInput = params.cfg.gateway?.auth?.token;
const tokenRef = resolveSecretInputRef({
value: tokenInput,
defaults: params.cfg.secrets?.defaults,
}).ref;
if (!tokenRef) {
return trimToUndefined(tokenInput);
}
const resolved = await resolveConfiguredSecretInputString({
config: params.cfg,
const resolved = await resolveGatewayAuthToken({
cfg: params.cfg,
env,
value: tokenInput,
path: "gateway.auth.token",
envFallback: "never",
unresolvedReasonStyle: "detailed",
});
if (resolved.value) {
return resolved.value;
if (resolved.token) {
return resolved.token;
}
if (!resolved.secretRefConfigured) {
return undefined;
}
throw new GatewaySecretRefUnavailableError("gateway.auth.token");
}

View File

@@ -15,11 +15,7 @@ import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
import { resolveGatewayService } from "../../daemon/service.js";
import {
isGatewaySecretRefUnavailableError,
resolveGatewayProbeCredentialsFromConfig,
trimToUndefined,
} from "../../gateway/credentials.js";
import { trimToUndefined } from "../../gateway/credentials.js";
import {
inspectBestEffortPrimaryTailnetIPv4,
resolveBestEffortGatewayBindHostForDisplay,
@@ -190,10 +186,6 @@ function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: bool
return true;
}
function parseGatewaySecretRefPathFromError(error: unknown): string | null {
return isGatewaySecretRefUnavailableError(error) ? error.path : null;
}
async function loadDaemonConfigContext(
serviceEnv?: Record<string, string>,
): Promise<DaemonConfigContext> {
@@ -408,43 +400,20 @@ export async function gatherDaemonStatus(
let rpcAuthWarning: string | undefined;
if (opts.probe) {
const probeMode = daemonCfg.gateway?.mode === "remote" ? "remote" : "local";
try {
daemonProbeAuth = resolveGatewayProbeCredentialsFromConfig({
cfg: daemonCfg,
mode: probeMode,
env: mergedDaemonEnv as NodeJS.ProcessEnv,
explicitAuth: {
token: opts.rpc.token,
password: opts.rpc.password,
},
});
} catch (error) {
const refPath = parseGatewaySecretRefPathFromError(error);
if (!refPath) {
throw error;
}
try {
daemonProbeAuth = await loadGatewayProbeAuthModule().then(
({ resolveGatewayProbeAuthWithSecretInputs }) =>
resolveGatewayProbeAuthWithSecretInputs({
cfg: daemonCfg,
mode: probeMode,
env: mergedDaemonEnv as NodeJS.ProcessEnv,
explicitAuth: {
token: opts.rpc.token,
password: opts.rpc.password,
},
}),
);
} catch (fallbackError) {
const fallbackRefPath = parseGatewaySecretRefPathFromError(fallbackError);
if (!fallbackRefPath) {
throw fallbackError;
}
daemonProbeAuth = undefined;
rpcAuthWarning = `${fallbackRefPath} SecretRef is unavailable in this command path; probing without configured auth credentials.`;
}
}
const probeAuthResolution = await loadGatewayProbeAuthModule().then(
({ resolveGatewayProbeAuthSafeWithSecretInputs }) =>
resolveGatewayProbeAuthSafeWithSecretInputs({
cfg: daemonCfg,
mode: probeMode,
env: mergedDaemonEnv as NodeJS.ProcessEnv,
explicitAuth: {
token: opts.rpc.token,
password: opts.rpc.password,
},
}),
);
daemonProbeAuth = probeAuthResolution.auth;
rpcAuthWarning = probeAuthResolution.warning;
}
const rpc = opts.probe

View File

@@ -1,7 +1,5 @@
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.js";
import { readGatewayTokenEnv } from "../gateway/credentials.js";
import { resolveConfiguredSecretInputWithFallback } from "../gateway/resolve-configured-secret-input-string.js";
import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js";
import { copyToClipboard } from "../infra/clipboard.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -16,37 +14,6 @@ type DashboardOptions = {
noOpen?: boolean;
};
async function resolveDashboardToken(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): Promise<{
token?: string;
source?: "config" | "env" | "secretRef";
unresolvedRefReason?: string;
tokenSecretRefConfigured: boolean;
}> {
const resolved = await resolveConfiguredSecretInputWithFallback({
config: cfg,
env,
value: cfg.gateway?.auth?.token,
path: "gateway.auth.token",
readFallback: () => readGatewayTokenEnv(env),
});
return {
token: resolved.value,
source:
resolved.source === "config"
? "config"
: resolved.source === "secretRef"
? "secretRef"
: resolved.source === "fallback"
? "env"
: undefined,
unresolvedRefReason: resolved.unresolvedRefReason,
tokenSecretRefConfigured: resolved.secretRefConfigured,
};
}
export async function dashboardCommand(
runtime: RuntimeEnv = defaultRuntime,
options: DashboardOptions = {},
@@ -57,7 +24,11 @@ export async function dashboardCommand(
const bind = cfg.gateway?.bind ?? "loopback";
const basePath = cfg.gateway?.controlUi?.basePath;
const customBindHost = cfg.gateway?.customBindHost;
const resolvedToken = await resolveDashboardToken(cfg, process.env);
const resolvedToken = await resolveGatewayAuthToken({
cfg,
env: process.env,
envFallback: "always",
});
const token = resolvedToken.token ?? "";
// LAN URLs fail secure-context checks in browsers.
@@ -69,14 +40,14 @@ export async function dashboardCommand(
basePath,
});
// Avoid embedding externally managed SecretRef tokens in terminal/clipboard/browser args.
const includeTokenInUrl = token.length > 0 && !resolvedToken.tokenSecretRefConfigured;
const includeTokenInUrl = token.length > 0 && !resolvedToken.secretRefConfigured;
// Prefer URL fragment to avoid leaking auth tokens via query params.
const dashboardUrl = includeTokenInUrl
? `${links.httpUrl}#token=${encodeURIComponent(token)}`
: links.httpUrl;
runtime.log(`Dashboard URL: ${dashboardUrl}`);
if (resolvedToken.tokenSecretRefConfigured && token) {
if (resolvedToken.secretRefConfigured && token) {
runtime.log(
"Token auto-auth is disabled for SecretRef-managed gateway.auth.token; use your external token source if prompted.",
);

View File

@@ -1,22 +1,19 @@
import type { OpenClawConfig } from "../config/config.js";
export { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-policy.js";
import { readGatewayTokenEnv } from "../gateway/credentials.js";
import { resolveConfiguredSecretInputWithFallback } from "../gateway/resolve-configured-secret-input-string.js";
import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js";
export async function resolveGatewayAuthTokenForService(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): Promise<{ token?: string; unavailableReason?: string }> {
const resolved = await resolveConfiguredSecretInputWithFallback({
config: cfg,
const resolved = await resolveGatewayAuthToken({
cfg,
env,
value: cfg.gateway?.auth?.token,
path: "gateway.auth.token",
unresolvedReasonStyle: "detailed",
readFallback: () => readGatewayTokenEnv(env),
envFallback: "always",
});
if (resolved.value) {
return { token: resolved.value };
if (resolved.token) {
return { token: resolved.token };
}
if (!resolved.secretRefConfigured) {
return {};

View File

@@ -7,10 +7,8 @@ import {
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-policy.js";
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { readGatewayTokenEnv } from "../gateway/credentials.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import { randomToken } from "./onboard-helpers.js";
type GatewayInstallTokenOptions = {
@@ -28,41 +26,6 @@ export type GatewayInstallTokenResolution = {
warnings: string[];
};
function resolveConfiguredGatewayInstallToken(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
explicitToken?: string;
tokenRef: unknown;
}): string | undefined {
const configToken =
params.tokenRef || typeof params.config.gateway?.auth?.token !== "string"
? undefined
: params.config.gateway.auth.token.trim() || undefined;
const explicitToken = params.explicitToken?.trim() || undefined;
const envToken = readGatewayTokenEnv(params.env);
return explicitToken || configToken || (params.tokenRef ? undefined : envToken);
}
async function validateGatewayInstallTokenSecretRef(params: {
tokenRef: NonNullable<ReturnType<typeof resolveSecretInputRef>["ref"]>;
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<string | undefined> {
try {
const resolved = await resolveSecretRefValues([params.tokenRef], {
config: params.config,
env: params.env,
});
const value = resolved.get(secretRefKey(params.tokenRef));
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.token resolved to an empty or non-string value.");
}
return undefined;
} catch (err) {
return `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`;
}
}
async function maybePersistAutoGeneratedGatewayInstallToken(params: {
token: string;
config: OpenClawConfig;
@@ -128,13 +91,14 @@ export async function resolveGatewayInstallToken(
): Promise<GatewayInstallTokenResolution> {
const cfg = options.config;
const warnings: string[] = [];
const tokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref;
const tokenRefConfigured = Boolean(tokenRef);
if (hasAmbiguousGatewayAuthModeConfig(cfg)) {
const tokenRefConfigured = Boolean(
resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref,
);
return {
token: undefined,
tokenRefConfigured,
@@ -150,31 +114,43 @@ export async function resolveGatewayInstallToken(
});
const needsToken =
shouldRequireGatewayTokenForInstall(cfg, options.env) && !resolvedAuth.allowTailscale;
if (!needsToken) {
const tokenRefConfigured = Boolean(
resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref,
);
return {
token: undefined,
tokenRefConfigured,
unavailableReason: undefined,
warnings,
};
}
let token = resolveConfiguredGatewayInstallToken({
config: cfg,
const resolvedToken = await resolveGatewayAuthToken({
cfg,
env: options.env,
explicitToken: options.explicitToken,
tokenRef,
envFallback: "no-secret-ref",
unresolvedReasonStyle: "detailed",
});
const tokenRefConfigured = resolvedToken.secretRefConfigured;
let token = resolvedToken.source === "secretRef" ? undefined : resolvedToken.token;
let unavailableReason: string | undefined;
if (tokenRef && !token && needsToken) {
unavailableReason = await validateGatewayInstallTokenSecretRef({
tokenRef,
config: cfg,
env: options.env,
});
if (!unavailableReason) {
warnings.push(
"gateway.auth.token is SecretRef-managed; install will not persist a resolved token in service environment. Ensure the SecretRef is resolvable in the daemon runtime context.",
);
}
if (tokenRefConfigured && resolvedToken.source === "secretRef" && needsToken) {
warnings.push(
"gateway.auth.token is SecretRef-managed; install will not persist a resolved token in service environment. Ensure the SecretRef is resolvable in the daemon runtime context.",
);
} else if (tokenRefConfigured && !token && needsToken) {
unavailableReason = `gateway.auth.token SecretRef is configured but unresolved (${resolvedToken.unresolvedRefReason ?? "unknown reason"}).`;
}
const allowAutoGenerate = options.autoGenerateWhenMissing ?? false;
const persistGeneratedToken = options.persistGeneratedToken ?? false;
if (!token && needsToken && !tokenRef && allowAutoGenerate) {
if (!token && !tokenRefConfigured && allowAutoGenerate) {
token = randomToken();
warnings.push(
persistGeneratedToken

View File

@@ -2,10 +2,9 @@ import { parseTimeoutMsWithFallback } from "../../cli/parse-timeout.js";
import { resolveGatewayPort } from "../../config/config.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../../config/types.js";
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
import { readGatewayPasswordEnv, readGatewayTokenEnv } from "../../gateway/credentials.js";
import { resolveGatewayProbeSurfaceAuth } from "../../gateway/auth-surface-resolution.js";
import { isLoopbackHost } from "../../gateway/net.js";
import type { GatewayProbeResult } from "../../gateway/probe.js";
import { resolveConfiguredSecretInputString } from "../../gateway/resolve-configured-secret-input-string.js";
import { inspectBestEffortPrimaryTailnetIPv4 } from "../../infra/network-discovery-display.js";
import { colorize, theme } from "../../terminal/theme.js";
import { pickGatewaySelfPresence } from "../gateway-presence.js";
@@ -169,92 +168,10 @@ export async function resolveAuthForTarget(
return { token: tokenOverride, password: passwordOverride };
}
const diagnostics: string[] = [];
const authMode = cfg.gateway?.auth?.mode;
const tokenOnly = authMode === "token";
const passwordOnly = authMode === "password";
const resolveToken = async (value: unknown, path: string): Promise<string | undefined> => {
const tokenResolution = await resolveConfiguredSecretInputString({
config: cfg,
env: process.env,
value,
path,
unresolvedReasonStyle: "detailed",
});
if (tokenResolution.unresolvedRefReason) {
diagnostics.push(tokenResolution.unresolvedRefReason);
}
return tokenResolution.value;
};
const resolvePassword = async (value: unknown, path: string): Promise<string | undefined> => {
const passwordResolution = await resolveConfiguredSecretInputString({
config: cfg,
env: process.env,
value,
path,
unresolvedReasonStyle: "detailed",
});
if (passwordResolution.unresolvedRefReason) {
diagnostics.push(passwordResolution.unresolvedRefReason);
}
return passwordResolution.value;
};
const withDiagnostics = <T extends { token?: string; password?: string }>(result: T) =>
diagnostics.length > 0 ? { ...result, diagnostics } : result;
if (target.kind === "configRemote" || target.kind === "sshTunnel") {
const remoteTokenValue = cfg.gateway?.remote?.token;
const remotePasswordValue = (cfg.gateway?.remote as { password?: unknown } | undefined)
?.password;
const token = await resolveToken(remoteTokenValue, "gateway.remote.token");
const password = token
? undefined
: await resolvePassword(remotePasswordValue, "gateway.remote.password");
return withDiagnostics({ token, password });
}
const authDisabled = authMode === "none" || authMode === "trusted-proxy";
if (authDisabled) {
return {};
}
const envToken = readGatewayTokenEnv();
const envPassword = readGatewayPasswordEnv();
if (tokenOnly) {
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
if (token) {
return withDiagnostics({ token });
}
if (envToken) {
return { token: envToken };
}
return withDiagnostics({});
}
if (passwordOnly) {
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
if (password) {
return withDiagnostics({ password });
}
if (envPassword) {
return { password: envPassword };
}
return withDiagnostics({});
}
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
if (token) {
return withDiagnostics({ token });
}
if (envToken) {
return { token: envToken };
}
if (envPassword) {
return withDiagnostics({ password: envPassword });
}
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
return withDiagnostics({ token, password });
return resolveGatewayProbeSurfaceAuth({
config: cfg,
surface: target.kind === "configRemote" || target.kind === "sshTunnel" ? "remote" : "local",
});
}
export { pickGatewaySelfPresence };

View File

@@ -1,5 +1,8 @@
import type { loadConfig } from "../config/config.js";
import { resolveGatewayProbeAuthSafeWithSecretInputs } from "../gateway/probe-auth.js";
import {
resolveGatewayProbeAuthSafeWithSecretInputs,
resolveGatewayProbeTarget,
} from "../gateway/probe-auth.js";
export { pickGatewaySelfPresence } from "./gateway-presence.js";
export async function resolveGatewayProbeAuthResolution(
@@ -11,9 +14,10 @@ export async function resolveGatewayProbeAuthResolution(
};
warning?: string;
}> {
const target = resolveGatewayProbeTarget(cfg);
return resolveGatewayProbeAuthSafeWithSecretInputs({
cfg,
mode: cfg.gateway?.mode === "remote" ? "remote" : "local",
mode: target.mode,
env: process.env,
});
}

View File

@@ -1,35 +1,139 @@
import type { GatewayAuthConfig, OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { resolveRequiredConfiguredSecretRefInputString } from "./resolve-configured-secret-input-string.js";
import {
assignResolvedGatewaySecretInput,
readGatewaySecretInputValue,
type SupportedGatewaySecretInputPath,
} from "./secret-input-paths.js";
export function withGatewayAuthPassword(cfg: OpenClawConfig, password: string): OpenClawConfig {
return {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
password,
},
},
};
}
export type GatewayAuthSecretInputPath = Extract<
SupportedGatewaySecretInputPath,
"gateway.auth.token" | "gateway.auth.password"
>;
function shouldResolveGatewayPasswordSecretRef(params: {
export type GatewayAuthSecretRefResolutionParams = {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
mode?: GatewayAuthConfig["mode"];
hasPasswordCandidate: boolean;
hasTokenCandidate: boolean;
};
export function hasConfiguredGatewayAuthSecretInput(
cfg: OpenClawConfig,
path: GatewayAuthSecretInputPath,
): boolean {
return hasConfiguredSecretInput(readGatewaySecretInputValue(cfg, path), cfg.secrets?.defaults);
}
export function shouldResolveGatewayAuthSecretRef(params: {
mode?: GatewayAuthConfig["mode"];
path: GatewayAuthSecretInputPath;
hasPasswordCandidate: boolean;
hasTokenCandidate: boolean;
}): boolean {
if (params.hasPasswordCandidate) {
const isTokenPath = params.path === "gateway.auth.token";
const hasPathCandidate = isTokenPath ? params.hasTokenCandidate : params.hasPasswordCandidate;
if (hasPathCandidate) {
return false;
}
if (params.mode === "password") {
if (params.mode === (isTokenPath ? "token" : "password")) {
return true;
}
if (params.mode === "token" || params.mode === "none" || params.mode === "trusted-proxy") {
return false;
}
return !params.hasTokenCandidate;
if (params.mode === "password") {
return !isTokenPath;
}
return isTokenPath ? !params.hasPasswordCandidate : !params.hasTokenCandidate;
}
export function shouldResolveGatewayTokenSecretRef(
params: Omit<GatewayAuthSecretRefResolutionParams, "cfg" | "env">,
): boolean {
return shouldResolveGatewayAuthSecretRef({
mode: params.mode,
path: "gateway.auth.token",
hasPasswordCandidate: params.hasPasswordCandidate,
hasTokenCandidate: params.hasTokenCandidate,
});
}
export function shouldResolveGatewayPasswordSecretRef(
params: Omit<GatewayAuthSecretRefResolutionParams, "cfg" | "env">,
): boolean {
return shouldResolveGatewayAuthSecretRef({
mode: params.mode,
path: "gateway.auth.password",
hasPasswordCandidate: params.hasPasswordCandidate,
hasTokenCandidate: params.hasTokenCandidate,
});
}
export async function resolveGatewayAuthSecretRefValue(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
path: GatewayAuthSecretInputPath;
shouldResolve: boolean;
}): Promise<string | undefined> {
if (!params.shouldResolve) {
return undefined;
}
const value = await resolveRequiredConfiguredSecretRefInputString({
config: params.cfg,
env: params.env,
value: readGatewaySecretInputValue(params.cfg, params.path),
path: params.path,
});
if (!value) {
return undefined;
}
return value;
}
export async function resolveGatewayTokenSecretRefValue(
params: GatewayAuthSecretRefResolutionParams,
): Promise<string | undefined> {
return resolveGatewayAuthSecretRefValue({
cfg: params.cfg,
env: params.env,
path: "gateway.auth.token",
shouldResolve: shouldResolveGatewayTokenSecretRef(params),
});
}
export async function resolveGatewayPasswordSecretRefValue(
params: GatewayAuthSecretRefResolutionParams,
): Promise<string | undefined> {
return resolveGatewayAuthSecretRefValue({
cfg: params.cfg,
env: params.env,
path: "gateway.auth.password",
shouldResolve: shouldResolveGatewayPasswordSecretRef(params),
});
}
export async function resolveGatewayAuthSecretRef(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
path: GatewayAuthSecretInputPath;
shouldResolve: boolean;
}): Promise<OpenClawConfig> {
const value = await resolveGatewayAuthSecretRefValue(params);
if (!value) {
return params.cfg;
}
const nextConfig = structuredClone(params.cfg);
nextConfig.gateway ??= {};
nextConfig.gateway.auth ??= {};
assignResolvedGatewaySecretInput({
config: nextConfig,
path: params.path,
value,
});
return nextConfig;
}
export async function resolveGatewayPasswordSecretRef(params: {
@@ -39,31 +143,30 @@ export async function resolveGatewayPasswordSecretRef(params: {
hasPasswordCandidate: boolean;
hasTokenCandidate: boolean;
}): Promise<OpenClawConfig> {
const authPassword = params.cfg.gateway?.auth?.password;
const { ref } = resolveSecretInputRef({
value: authPassword,
defaults: params.cfg.secrets?.defaults,
});
if (!ref) {
return params.cfg;
}
if (
!shouldResolveGatewayPasswordSecretRef({
mode: params.mode,
hasPasswordCandidate: params.hasPasswordCandidate,
hasTokenCandidate: params.hasTokenCandidate,
})
) {
return params.cfg;
}
const value = await resolveRequiredConfiguredSecretRefInputString({
config: params.cfg,
return resolveGatewayAuthSecretRef({
cfg: params.cfg,
env: params.env,
value: authPassword,
path: "gateway.auth.password",
shouldResolve: shouldResolveGatewayPasswordSecretRef(params),
});
}
export async function materializeGatewayAuthSecretRefs(
params: GatewayAuthSecretRefResolutionParams,
): Promise<OpenClawConfig> {
const cfgWithToken = await resolveGatewayAuthSecretRef({
cfg: params.cfg,
env: params.env,
path: "gateway.auth.token",
shouldResolve: shouldResolveGatewayTokenSecretRef(params),
});
return await resolveGatewayPasswordSecretRef({
cfg: cfgWithToken,
env: params.env,
mode: params.mode,
hasPasswordCandidate: params.hasPasswordCandidate,
hasTokenCandidate:
params.hasTokenCandidate ||
hasConfiguredGatewayAuthSecretInput(cfgWithToken, "gateway.auth.token"),
});
if (!value) {
return params.cfg;
}
return withGatewayAuthPassword(params.cfg, value);
}

View File

@@ -0,0 +1,289 @@
import type { OpenClawConfig } from "../config/types.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import {
readGatewayPasswordEnv,
readGatewayTokenEnv,
trimToUndefined,
type ExplicitGatewayAuth,
} from "./credentials.js";
import { resolveConfiguredSecretInputString } from "./resolve-configured-secret-input-string.js";
type GatewayCredentialPath =
| "gateway.auth.token"
| "gateway.auth.password"
| "gateway.remote.token"
| "gateway.remote.password";
type ResolvedGatewayCredential = {
value?: string;
unresolvedRefReason?: string;
};
async function resolveGatewayCredential(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
diagnostics: string[];
path: GatewayCredentialPath;
value: unknown;
}): Promise<ResolvedGatewayCredential> {
const resolved = await resolveConfiguredSecretInputString({
config: params.config,
env: params.env,
value: params.value,
path: params.path,
unresolvedReasonStyle: "detailed",
});
if (resolved.unresolvedRefReason) {
params.diagnostics.push(resolved.unresolvedRefReason);
}
return resolved;
}
function withDiagnostics<T extends object>(params: {
diagnostics: string[];
result: T;
}): T & { diagnostics?: string[] } {
return params.diagnostics.length > 0
? { ...params.result, diagnostics: params.diagnostics }
: params.result;
}
export async function resolveGatewayProbeSurfaceAuth(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
surface: "local" | "remote";
}): Promise<{ token?: string; password?: string; diagnostics?: string[] }> {
const env = params.env ?? process.env;
const diagnostics: string[] = [];
const authMode = params.config.gateway?.auth?.mode;
if (params.surface === "remote") {
const remoteToken = await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.remote.token",
value: params.config.gateway?.remote?.token,
});
const remotePassword = remoteToken.value
? { value: undefined }
: await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.remote.password",
value: params.config.gateway?.remote?.password,
});
return withDiagnostics({
diagnostics,
result: { token: remoteToken.value, password: remotePassword.value },
});
}
if (authMode === "none" || authMode === "trusted-proxy") {
return {};
}
const envToken = readGatewayTokenEnv(env);
const envPassword = readGatewayPasswordEnv(env);
if (authMode === "token") {
const token = await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.auth.token",
value: params.config.gateway?.auth?.token,
});
return token.value
? withDiagnostics({ diagnostics, result: { token: token.value } })
: envToken
? { token: envToken }
: withDiagnostics({ diagnostics, result: {} });
}
if (authMode === "password") {
const password = await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.auth.password",
value: params.config.gateway?.auth?.password,
});
return password.value
? withDiagnostics({ diagnostics, result: { password: password.value } })
: envPassword
? { password: envPassword }
: withDiagnostics({ diagnostics, result: {} });
}
const token = await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.auth.token",
value: params.config.gateway?.auth?.token,
});
if (token.value) {
return withDiagnostics({ diagnostics, result: { token: token.value } });
}
if (envToken) {
return { token: envToken };
}
if (envPassword) {
return withDiagnostics({ diagnostics, result: { password: envPassword } });
}
const password = await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.auth.password",
value: params.config.gateway?.auth?.password,
});
return withDiagnostics({
diagnostics,
result: { token: token.value, password: password.value },
});
}
export async function resolveGatewayInteractiveSurfaceAuth(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
explicitAuth?: ExplicitGatewayAuth;
surface: "local" | "remote";
}): Promise<{
token?: string;
password?: string;
failureReason?: string;
}> {
const env = params.env ?? process.env;
const diagnostics: string[] = [];
const explicitToken = trimToUndefined(params.explicitAuth?.token);
const explicitPassword = trimToUndefined(params.explicitAuth?.password);
const envToken = readGatewayTokenEnv(env);
const envPassword = readGatewayPasswordEnv(env);
if (params.surface === "remote") {
const remoteToken = explicitToken
? { value: explicitToken }
: await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.remote.token",
value: params.config.gateway?.remote?.token,
});
const remotePassword =
explicitPassword || envPassword
? { value: explicitPassword ?? envPassword }
: await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.remote.password",
value: params.config.gateway?.remote?.password,
});
const token = explicitToken ?? remoteToken.value;
const password = explicitPassword ?? envPassword ?? remotePassword.value;
return token || password
? { token, password }
: {
failureReason:
remoteToken.unresolvedRefReason ??
remotePassword.unresolvedRefReason ??
"Missing gateway auth credentials.",
};
}
const authMode = params.config.gateway?.auth?.mode;
if (authMode === "none" || authMode === "trusted-proxy") {
return {
token: explicitToken ?? envToken,
password: explicitPassword ?? envPassword,
};
}
const hasConfiguredToken = hasConfiguredSecretInput(
params.config.gateway?.auth?.token,
params.config.secrets?.defaults,
);
const hasConfiguredPassword = hasConfiguredSecretInput(
params.config.gateway?.auth?.password,
params.config.secrets?.defaults,
);
const resolveToken = async () => {
const localToken = explicitToken
? { value: explicitToken }
: await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.auth.token",
value: params.config.gateway?.auth?.token,
});
const token = explicitToken ?? localToken.value ?? envToken;
return {
token,
failureReason: token
? undefined
: (localToken.unresolvedRefReason ?? "Missing gateway auth token."),
};
};
const resolvePassword = async () => {
const localPassword =
explicitPassword || envPassword
? { value: explicitPassword ?? envPassword }
: await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.auth.password",
value: params.config.gateway?.auth?.password,
});
const password = explicitPassword ?? envPassword ?? localPassword.value;
return {
password,
failureReason: password
? undefined
: (localPassword.unresolvedRefReason ?? "Missing gateway auth password."),
};
};
if (authMode === "password") {
const password = await resolvePassword();
return {
token: explicitToken ?? envToken,
password: password.password,
failureReason: password.failureReason,
};
}
if (authMode === "token") {
const token = await resolveToken();
return {
token: token.token,
password: explicitPassword ?? envPassword,
failureReason: token.failureReason,
};
}
const shouldUsePassword =
Boolean(explicitPassword ?? envPassword) || (hasConfiguredPassword && !hasConfiguredToken);
if (shouldUsePassword) {
const password = await resolvePassword();
return {
token: explicitToken ?? envToken,
password: password.password,
failureReason: password.failureReason,
};
}
const token = await resolveToken();
return {
token: token.token,
password: explicitPassword ?? envPassword,
failureReason: token.failureReason,
};
}

View File

@@ -0,0 +1,85 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { readGatewayTokenEnv, trimToUndefined } from "./credentials.js";
import {
resolveConfiguredSecretInputString,
type SecretInputUnresolvedReasonStyle,
} from "./resolve-configured-secret-input-string.js";
export type GatewayAuthTokenResolutionSource = "explicit" | "config" | "secretRef" | "env";
export type GatewayAuthTokenEnvFallback = "never" | "no-secret-ref" | "always";
export async function resolveGatewayAuthToken(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
explicitToken?: string;
envFallback?: GatewayAuthTokenEnvFallback;
unresolvedReasonStyle?: SecretInputUnresolvedReasonStyle;
}): Promise<{
token?: string;
source?: GatewayAuthTokenResolutionSource;
secretRefConfigured: boolean;
unresolvedRefReason?: string;
}> {
const explicitToken = trimToUndefined(params.explicitToken);
if (explicitToken) {
return {
token: explicitToken,
source: "explicit",
secretRefConfigured: false,
};
}
const tokenInput = params.cfg.gateway?.auth?.token;
const tokenRef = resolveSecretInputRef({
value: tokenInput,
defaults: params.cfg.secrets?.defaults,
}).ref;
const envFallback = params.envFallback ?? "always";
const envToken = readGatewayTokenEnv(params.env);
if (!tokenRef) {
const configToken = trimToUndefined(tokenInput);
if (configToken) {
return {
token: configToken,
source: "config",
secretRefConfigured: false,
};
}
if (envFallback !== "never" && envToken) {
return {
token: envToken,
source: "env",
secretRefConfigured: false,
};
}
return { secretRefConfigured: false };
}
const resolved = await resolveConfiguredSecretInputString({
config: params.cfg,
env: params.env,
value: tokenInput,
path: "gateway.auth.token",
unresolvedReasonStyle: params.unresolvedReasonStyle,
});
if (resolved.value) {
return {
token: resolved.value,
source: "secretRef",
secretRefConfigured: true,
};
}
if (envFallback === "always" && envToken) {
return {
token: envToken,
source: "env",
secretRefConfigured: true,
};
}
return {
secretRefConfigured: true,
unresolvedRefReason: resolved.unresolvedRefReason,
};
}

View File

@@ -43,6 +43,14 @@ import {
type OperatorScope,
} from "./method-scopes.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import {
ALL_GATEWAY_SECRET_INPUT_PATHS,
assignResolvedGatewaySecretInput,
isSupportedGatewaySecretInputPath,
isTokenGatewaySecretInputPath,
readGatewaySecretInputValue,
type SupportedGatewaySecretInputPath,
} from "./secret-input-paths.js";
export type { GatewayConnectionDetails };
type CallGatewayBaseOptions = {
@@ -364,44 +372,6 @@ async function resolveGatewayCredentialsWithEnv(
return resolveGatewayCredentialsFromConfigWithSecretInputs({ context, env });
}
type SupportedGatewaySecretInputPath =
| "gateway.auth.token"
| "gateway.auth.password"
| "gateway.remote.token"
| "gateway.remote.password";
const ALL_GATEWAY_SECRET_INPUT_PATHS: SupportedGatewaySecretInputPath[] = [
"gateway.auth.token",
"gateway.auth.password",
"gateway.remote.token",
"gateway.remote.password",
];
function isSupportedGatewaySecretInputPath(path: string): path is SupportedGatewaySecretInputPath {
return (
path === "gateway.auth.token" ||
path === "gateway.auth.password" ||
path === "gateway.remote.token" ||
path === "gateway.remote.password"
);
}
function readGatewaySecretInputValue(
config: OpenClawConfig,
path: SupportedGatewaySecretInputPath,
): unknown {
if (path === "gateway.auth.token") {
return config.gateway?.auth?.token;
}
if (path === "gateway.auth.password") {
return config.gateway?.auth?.password;
}
if (path === "gateway.remote.token") {
return config.gateway?.remote?.token;
}
return config.gateway?.remote?.password;
}
function hasConfiguredGatewaySecretRef(
config: OpenClawConfig,
path: SupportedGatewaySecretInputPath,
@@ -436,10 +406,6 @@ function resolveGatewayCredentialsFromConfigOptions(params: {
} as const;
}
function isTokenGatewaySecretInputPath(path: SupportedGatewaySecretInputPath): boolean {
return path === "gateway.auth.token" || path === "gateway.remote.token";
}
function localAuthModeAllowsGatewaySecretInputPath(params: {
authMode: string | undefined;
path: SupportedGatewaySecretInputPath;
@@ -515,68 +481,14 @@ async function resolveConfiguredGatewaySecretInput(params: {
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,
config: params.config,
value: readGatewaySecretInputValue(params.config, params.path),
path: params.path,
env: params.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;

View File

@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockState = vi.hoisted(() => ({
buildGatewayConnectionDetails: vi.fn(),
resolveGatewayConnectionAuth: vi.fn(),
}));
vi.mock("./call.js", () => ({
buildGatewayConnectionDetails: (...args: unknown[]) =>
mockState.buildGatewayConnectionDetails(...args),
}));
vi.mock("./connection-auth.js", () => ({
resolveGatewayConnectionAuth: (...args: unknown[]) =>
mockState.resolveGatewayConnectionAuth(...args),
}));
const { resolveGatewayClientBootstrap, resolveGatewayUrlOverrideSource } =
await import("./client-bootstrap.js");
describe("resolveGatewayUrlOverrideSource", () => {
it("maps override url sources only", () => {
expect(resolveGatewayUrlOverrideSource("cli --url")).toBe("cli");
expect(resolveGatewayUrlOverrideSource("env OPENCLAW_GATEWAY_URL")).toBe("env");
expect(resolveGatewayUrlOverrideSource("config gateway.remote.url")).toBeUndefined();
});
});
describe("resolveGatewayClientBootstrap", () => {
beforeEach(() => {
mockState.buildGatewayConnectionDetails.mockReset();
mockState.resolveGatewayConnectionAuth.mockReset();
mockState.resolveGatewayConnectionAuth.mockResolvedValue({
token: undefined,
password: undefined,
});
});
it("passes cli override context into shared auth resolution", async () => {
mockState.buildGatewayConnectionDetails.mockReturnValue({
url: "wss://override.example/ws",
urlSource: "cli --url",
});
const result = await resolveGatewayClientBootstrap({
config: {} as never,
gatewayUrl: "wss://override.example/ws",
env: process.env,
});
expect(result).toEqual({
url: "wss://override.example/ws",
urlSource: "cli --url",
auth: {
token: undefined,
password: undefined,
},
});
expect(mockState.resolveGatewayConnectionAuth).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
urlOverride: "wss://override.example/ws",
urlOverrideSource: "cli",
}),
);
});
it("does not mark config-derived urls as overrides", async () => {
mockState.buildGatewayConnectionDetails.mockReturnValue({
url: "wss://gateway.example/ws",
urlSource: "config gateway.remote.url",
});
await resolveGatewayClientBootstrap({
config: {} as never,
env: process.env,
});
expect(mockState.resolveGatewayConnectionAuth).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
urlOverride: undefined,
urlOverrideSource: undefined,
}),
);
});
});

View File

@@ -0,0 +1,46 @@
import type { OpenClawConfig } from "../config/config.js";
import { buildGatewayConnectionDetails } from "./call.js";
import type { ExplicitGatewayAuth } from "./call.js";
import { resolveGatewayConnectionAuth } from "./connection-auth.js";
export function resolveGatewayUrlOverrideSource(urlSource: string): "cli" | "env" | undefined {
if (urlSource === "cli --url") {
return "cli";
}
if (urlSource === "env OPENCLAW_GATEWAY_URL") {
return "env";
}
return undefined;
}
export async function resolveGatewayClientBootstrap(params: {
config: OpenClawConfig;
gatewayUrl?: string;
explicitAuth?: ExplicitGatewayAuth;
env?: NodeJS.ProcessEnv;
}): Promise<{
url: string;
urlSource: string;
auth: {
token?: string;
password?: string;
};
}> {
const connection = buildGatewayConnectionDetails({
config: params.config,
url: params.gatewayUrl,
});
const urlOverrideSource = resolveGatewayUrlOverrideSource(connection.urlSource);
const auth = await resolveGatewayConnectionAuth({
config: params.config,
explicitAuth: params.explicitAuth,
env: params.env ?? process.env,
urlOverride: urlOverrideSource ? connection.url : undefined,
urlOverrideSource,
});
return {
url: connection.url,
urlSource: connection.urlSource,
auth,
};
}

View File

@@ -1,8 +1,7 @@
import type { OpenClawConfig } from "../config/config.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { buildGatewayConnectionDetails } from "./call.js";
import { resolveGatewayClientBootstrap } from "./client-bootstrap.js";
import { GatewayClient, type GatewayClientOptions } from "./client.js";
import { resolveGatewayConnectionAuth } from "./connection-auth.js";
export async function createOperatorApprovalsGatewayClient(
params: Pick<
@@ -13,27 +12,16 @@ export async function createOperatorApprovalsGatewayClient(
gatewayUrl?: string;
},
): Promise<GatewayClient> {
const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({
config: params.config,
url: params.gatewayUrl,
});
const gatewayUrlOverrideSource =
urlSource === "cli --url"
? "cli"
: urlSource === "env OPENCLAW_GATEWAY_URL"
? "env"
: undefined;
const auth = await resolveGatewayConnectionAuth({
const bootstrap = await resolveGatewayClientBootstrap({
config: params.config,
gatewayUrl: params.gatewayUrl,
env: process.env,
urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined,
urlOverrideSource: gatewayUrlOverrideSource,
});
return new GatewayClient({
url: gatewayUrl,
token: auth.token,
password: auth.password,
url: bootstrap.url,
token: bootstrap.auth.token,
password: bootstrap.auth.password,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: params.clientDisplayName,
mode: GATEWAY_CLIENT_MODES.BACKEND,

View File

@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
import {
resolveGatewayProbeAuthSafe,
resolveGatewayProbeAuthSafeWithSecretInputs,
resolveGatewayProbeTarget,
resolveGatewayProbeAuthWithSecretInputs,
} from "./probe-auth.js";
@@ -108,6 +109,39 @@ describe("resolveGatewayProbeAuthSafe", () => {
});
});
describe("resolveGatewayProbeTarget", () => {
it("falls back to local probe mode when remote mode is configured without remote url", () => {
expect(
resolveGatewayProbeTarget({
gateway: {
mode: "remote",
},
} as OpenClawConfig),
).toEqual({
gatewayMode: "remote",
mode: "local",
remoteUrlMissing: true,
});
});
it("keeps remote probe mode when remote url is configured", () => {
expect(
resolveGatewayProbeTarget({
gateway: {
mode: "remote",
remote: {
url: "wss://gateway.example",
},
},
} as OpenClawConfig),
).toEqual({
gatewayMode: "remote",
mode: "remote",
remoteUrlMissing: false,
});
});
});
describe("resolveGatewayProbeAuthSafeWithSecretInputs", () => {
it("resolves env SecretRef token via async secret-inputs path", async () => {
const result = await resolveGatewayProbeAuthSafeWithSecretInputs({

View File

@@ -6,6 +6,12 @@ import {
resolveGatewayProbeCredentialsFromConfig,
} from "./credentials.js";
export type GatewayProbeTargetResolution = {
gatewayMode: "local" | "remote";
mode: "local" | "remote";
remoteUrlMissing: boolean;
};
function buildGatewayProbeCredentialPolicy(params: {
cfg: OpenClawConfig;
mode: "local" | "remote";
@@ -23,6 +29,42 @@ function buildGatewayProbeCredentialPolicy(params: {
};
}
function resolveExplicitProbeAuth(explicitAuth?: ExplicitGatewayAuth): {
token?: string;
password?: string;
} {
const token = explicitAuth?.token?.trim() || undefined;
const password = explicitAuth?.password?.trim() || undefined;
return { token, password };
}
function hasExplicitProbeAuth(auth: { token?: string; password?: string }): boolean {
return Boolean(auth.token || auth.password);
}
function buildUnresolvedProbeAuthWarning(path: string): string {
return `${path} SecretRef is unresolved in this command path; probing without configured auth credentials.`;
}
function resolveGatewayProbeWarning(error: unknown): string | undefined {
if (!isGatewaySecretRefUnavailableError(error)) {
throw error;
}
return buildUnresolvedProbeAuthWarning(error.path);
}
export function resolveGatewayProbeTarget(cfg: OpenClawConfig): GatewayProbeTargetResolution {
const gatewayMode = cfg.gateway?.mode === "remote" ? "remote" : "local";
const remoteUrlRaw =
typeof cfg.gateway?.remote?.url === "string" ? cfg.gateway.remote.url.trim() : "";
const remoteUrlMissing = gatewayMode === "remote" && !remoteUrlRaw;
return {
gatewayMode,
mode: gatewayMode === "remote" && !remoteUrlMissing ? "remote" : "local",
remoteUrlMissing,
};
}
export function resolveGatewayProbeAuth(params: {
cfg: OpenClawConfig;
mode: "local" | "remote";
@@ -57,14 +99,10 @@ export async function resolveGatewayProbeAuthSafeWithSecretInputs(params: {
auth: { token?: string; password?: string };
warning?: string;
}> {
const explicitToken = params.explicitAuth?.token?.trim();
const explicitPassword = params.explicitAuth?.password?.trim();
if (explicitToken || explicitPassword) {
const explicitAuth = resolveExplicitProbeAuth(params.explicitAuth);
if (hasExplicitProbeAuth(explicitAuth)) {
return {
auth: {
...(explicitToken ? { token: explicitToken } : {}),
...(explicitPassword ? { password: explicitPassword } : {}),
},
auth: explicitAuth,
};
}
@@ -72,12 +110,9 @@ export async function resolveGatewayProbeAuthSafeWithSecretInputs(params: {
const auth = await resolveGatewayProbeAuthWithSecretInputs(params);
return { auth };
} catch (error) {
if (!isGatewaySecretRefUnavailableError(error)) {
throw error;
}
return {
auth: {},
warning: `${error.path} SecretRef is unresolved in this command path; probing without configured auth credentials.`,
warning: resolveGatewayProbeWarning(error),
};
}
}
@@ -91,26 +126,19 @@ export function resolveGatewayProbeAuthSafe(params: {
auth: { token?: string; password?: string };
warning?: string;
} {
const explicitToken = params.explicitAuth?.token?.trim();
const explicitPassword = params.explicitAuth?.password?.trim();
if (explicitToken || explicitPassword) {
const explicitAuth = resolveExplicitProbeAuth(params.explicitAuth);
if (hasExplicitProbeAuth(explicitAuth)) {
return {
auth: {
...(explicitToken ? { token: explicitToken } : {}),
...(explicitPassword ? { password: explicitPassword } : {}),
},
auth: explicitAuth,
};
}
try {
return { auth: resolveGatewayProbeAuth(params) };
} catch (error) {
if (!isGatewaySecretRefUnavailableError(error)) {
throw error;
}
return {
auth: {},
warning: `${error.path} SecretRef is unresolved in this command path; probing without configured auth credentials.`,
warning: resolveGatewayProbeWarning(error),
};
}
}

View File

@@ -0,0 +1,69 @@
import type { OpenClawConfig } from "../config/config.js";
export type SupportedGatewaySecretInputPath =
| "gateway.auth.token"
| "gateway.auth.password"
| "gateway.remote.token"
| "gateway.remote.password";
export const ALL_GATEWAY_SECRET_INPUT_PATHS: SupportedGatewaySecretInputPath[] = [
"gateway.auth.token",
"gateway.auth.password",
"gateway.remote.token",
"gateway.remote.password",
];
export function isSupportedGatewaySecretInputPath(
path: string,
): path is SupportedGatewaySecretInputPath {
return ALL_GATEWAY_SECRET_INPUT_PATHS.includes(path as SupportedGatewaySecretInputPath);
}
export function readGatewaySecretInputValue(
config: OpenClawConfig,
path: SupportedGatewaySecretInputPath,
): unknown {
if (path === "gateway.auth.token") {
return config.gateway?.auth?.token;
}
if (path === "gateway.auth.password") {
return config.gateway?.auth?.password;
}
if (path === "gateway.remote.token") {
return config.gateway?.remote?.token;
}
return config.gateway?.remote?.password;
}
export 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;
}
}
export function isTokenGatewaySecretInputPath(path: SupportedGatewaySecretInputPath): boolean {
return path === "gateway.auth.token" || path === "gateway.remote.token";
}

View File

@@ -0,0 +1,317 @@
import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js";
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
import type {
ExecApprovalIdLookupResult,
ExecApprovalManager,
ExecApprovalRecord,
} from "../exec-approval-manager.js";
import { ErrorCodes, errorShape } from "../protocol/index.js";
import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js";
export const APPROVAL_NOT_FOUND_DETAILS = {
reason: ErrorCodes.APPROVAL_NOT_FOUND,
} as const;
type PendingApprovalLookupError =
| "missing"
| {
code: ErrorCodes.INVALID_REQUEST;
message: string;
};
type ApprovalTurnSourceFields = {
turnSourceChannel?: string | null;
turnSourceAccountId?: string | null;
};
type RequestedApprovalEvent<TPayload extends ApprovalTurnSourceFields> = {
id: string;
request: TPayload;
createdAtMs: number;
expiresAtMs: number;
};
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value;
}
export function isApprovalDecision(value: string): value is ExecApprovalDecision {
return value === "allow-once" || value === "allow-always" || value === "deny";
}
export function respondUnknownOrExpiredApproval(respond: RespondFn): void {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
}
function resolvePendingApprovalLookupError(params: {
resolvedId: ExecApprovalIdLookupResult;
exposeAmbiguousPrefixError?: boolean;
}): PendingApprovalLookupError {
if (params.resolvedId.kind === "none") {
return "missing";
}
if (params.resolvedId.kind === "ambiguous" && !params.exposeAmbiguousPrefixError) {
return "missing";
}
return {
code: ErrorCodes.INVALID_REQUEST,
message: "ambiguous approval id prefix; use the full id",
};
}
export function resolvePendingApprovalRecord<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
inputId: string;
exposeAmbiguousPrefixError?: boolean;
}):
| {
ok: true;
approvalId: string;
snapshot: ExecApprovalRecord<TPayload>;
}
| {
ok: false;
response: PendingApprovalLookupError;
} {
const resolvedId = params.manager.lookupPendingId(params.inputId);
if (resolvedId.kind !== "exact" && resolvedId.kind !== "prefix") {
return {
ok: false,
response: resolvePendingApprovalLookupError({
resolvedId,
exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError,
}),
};
}
const snapshot = params.manager.getSnapshot(resolvedId.id);
if (!snapshot || snapshot.resolvedAtMs !== undefined) {
return { ok: false, response: "missing" };
}
return { ok: true, approvalId: resolvedId.id, snapshot };
}
export function respondPendingApprovalLookupError(params: {
respond: RespondFn;
response: PendingApprovalLookupError;
}): void {
if (params.response === "missing") {
respondUnknownOrExpiredApproval(params.respond);
return;
}
params.respond(false, undefined, errorShape(params.response.code, params.response.message));
}
export async function handleApprovalWaitDecision<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
inputId: unknown;
respond: RespondFn;
}): Promise<void> {
const id = typeof params.inputId === "string" ? params.inputId.trim() : "";
if (!id) {
params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id is required"));
return;
}
const decisionPromise = params.manager.awaitDecision(id);
if (!decisionPromise) {
params.respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "approval expired or not found"),
);
return;
}
const snapshot = params.manager.getSnapshot(id);
const decision = await decisionPromise;
params.respond(
true,
{
id,
decision,
createdAtMs: snapshot?.createdAtMs,
expiresAtMs: snapshot?.expiresAtMs,
},
undefined,
);
}
export async function handlePendingApprovalRequest<
TPayload extends ApprovalTurnSourceFields,
>(params: {
manager: ExecApprovalManager<TPayload>;
record: ExecApprovalRecord<TPayload>;
decisionPromise: Promise<ExecApprovalDecision | null>;
respond: RespondFn;
context: GatewayRequestContext;
clientConnId?: string;
requestEventName: string;
requestEvent: RequestedApprovalEvent<TPayload>;
twoPhase: boolean;
deliverRequest: () => boolean | Promise<boolean>;
afterDecision?: (
decision: ExecApprovalDecision | null,
requestEvent: RequestedApprovalEvent<TPayload>,
) => Promise<void> | void;
afterDecisionErrorLabel?: string;
}): Promise<void> {
params.context.broadcast(params.requestEventName, params.requestEvent, { dropIfSlow: true });
const hasApprovalClients = params.context.hasExecApprovalClients?.(params.clientConnId) ?? false;
const hasTurnSourceRoute = hasApprovalTurnSourceRoute({
turnSourceChannel: params.record.request.turnSourceChannel,
turnSourceAccountId: params.record.request.turnSourceAccountId,
});
const deliveredResult = params.deliverRequest();
const delivered = isPromiseLike(deliveredResult) ? await deliveredResult : deliveredResult;
if (!hasApprovalClients && !hasTurnSourceRoute && !delivered) {
params.manager.expire(params.record.id, "no-approval-route");
params.respond(
true,
{
id: params.record.id,
decision: null,
createdAtMs: params.record.createdAtMs,
expiresAtMs: params.record.expiresAtMs,
},
undefined,
);
return;
}
if (params.twoPhase) {
params.respond(
true,
{
status: "accepted",
id: params.record.id,
createdAtMs: params.record.createdAtMs,
expiresAtMs: params.record.expiresAtMs,
},
undefined,
);
}
const decision = await params.decisionPromise;
if (params.afterDecision) {
try {
await params.afterDecision(decision, params.requestEvent);
} catch (err) {
params.context.logGateway?.error?.(
`${params.afterDecisionErrorLabel ?? "approval follow-up failed"}: ${String(err)}`,
);
}
}
params.respond(
true,
{
id: params.record.id,
decision,
createdAtMs: params.record.createdAtMs,
expiresAtMs: params.record.expiresAtMs,
},
undefined,
);
}
export async function handleApprovalResolve<TPayload, TResolvedEvent extends object>(params: {
manager: ExecApprovalManager<TPayload>;
inputId: string;
decision: ExecApprovalDecision;
respond: RespondFn;
context: GatewayRequestContext;
client: GatewayClient | null;
exposeAmbiguousPrefixError?: boolean;
validateDecision?: (snapshot: ExecApprovalRecord<TPayload>) =>
| {
message: string;
details?: Record<string, unknown>;
}
| null
| undefined;
resolvedEventName: string;
buildResolvedEvent: (params: {
approvalId: string;
decision: ExecApprovalDecision;
resolvedBy: string | null;
snapshot: ExecApprovalRecord<TPayload>;
nowMs: number;
}) => TResolvedEvent;
forwardResolved?: (event: TResolvedEvent) => Promise<void> | void;
forwardResolvedErrorLabel?: string;
extraResolvedHandlers?: Array<{
run: (event: TResolvedEvent) => Promise<void> | void;
errorLabel: string;
}>;
}): Promise<void> {
const resolved = resolvePendingApprovalRecord({
manager: params.manager,
inputId: params.inputId,
exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError,
});
if (!resolved.ok) {
respondPendingApprovalLookupError({ respond: params.respond, response: resolved.response });
return;
}
const validationError = params.validateDecision?.(resolved.snapshot);
if (validationError) {
params.respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
validationError.message,
validationError.details ? { details: validationError.details } : undefined,
),
);
return;
}
const resolvedBy =
params.client?.connect?.client?.displayName ?? params.client?.connect?.client?.id ?? null;
const ok = params.manager.resolve(resolved.approvalId, params.decision, resolvedBy);
if (!ok) {
respondUnknownOrExpiredApproval(params.respond);
return;
}
const resolvedEvent = params.buildResolvedEvent({
approvalId: resolved.approvalId,
decision: params.decision,
resolvedBy,
snapshot: resolved.snapshot,
nowMs: Date.now(),
});
params.context.broadcast(params.resolvedEventName, resolvedEvent, { dropIfSlow: true });
const followUps = [
params.forwardResolved
? {
run: params.forwardResolved,
errorLabel: params.forwardResolvedErrorLabel ?? "approval resolve follow-up failed",
}
: null,
...(params.extraResolvedHandlers ?? []),
].filter(
(
entry,
): entry is { run: (event: TResolvedEvent) => Promise<void> | void; errorLabel: string } =>
Boolean(entry),
);
for (const followUp of followUps) {
try {
await followUp.run(resolvedEvent);
} catch (err) {
params.context.logGateway?.error?.(`${followUp.errorLabel}: ${String(err)}`);
}
}
params.respond(true, { ok: true }, undefined);
}

View File

@@ -1,4 +1,3 @@
import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js";
import {
resolveExecApprovalCommandDisplay,
sanitizeExecApprovalDisplayText,
@@ -8,7 +7,6 @@ import {
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
resolveExecApprovalAllowedDecisions,
resolveExecApprovalRequestAllowedDecisions,
type ExecApprovalDecision,
type ExecApprovalRequest,
type ExecApprovalResolved,
} from "../../infra/exec-approvals.js";
@@ -26,12 +24,16 @@ import {
validateExecApprovalRequestParams,
validateExecApprovalResolveParams,
} from "../protocol/index.js";
import {
handleApprovalWaitDecision,
handlePendingApprovalRequest,
handleApprovalResolve,
isApprovalDecision,
respondPendingApprovalLookupError,
resolvePendingApprovalRecord,
} from "./approval-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
const APPROVAL_NOT_FOUND_DETAILS = {
reason: ErrorCodes.APPROVAL_NOT_FOUND,
} as const;
const APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS = {
reason: "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE",
} as const;
@@ -42,27 +44,6 @@ type ExecApprovalIosPushDelivery = {
handleExpired?: (request: ExecApprovalRequest) => Promise<void>;
};
function resolvePendingApprovalRecord(manager: ExecApprovalManager, inputId: string) {
const resolvedId = manager.lookupPendingId(inputId);
if (resolvedId.kind === "none") {
return { ok: false as const, response: "missing" as const };
}
if (resolvedId.kind === "ambiguous") {
return {
ok: false as const,
response: {
code: ErrorCodes.INVALID_REQUEST,
message: "ambiguous approval id prefix; use the full id",
},
};
}
const snapshot = manager.getSnapshot(resolvedId.id);
if (!snapshot || snapshot.resolvedAtMs !== undefined) {
return { ok: false as const, response: "missing" as const };
}
return { ok: true as const, approvalId: resolvedId.id, snapshot };
}
export function createExecApprovalHandlers(
manager: ExecApprovalManager,
opts?: { forwarder?: ExecApprovalForwarder; iosPushDelivery?: ExecApprovalIosPushDelivery },
@@ -83,19 +64,13 @@ export function createExecApprovalHandlers(
return;
}
const p = params as { id: string };
const resolved = resolvePendingApprovalRecord(manager, p.id);
const resolved = resolvePendingApprovalRecord({
manager,
inputId: p.id,
exposeAmbiguousPrefixError: true,
});
if (!resolved.ok) {
if (resolved.response === "missing") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
return;
}
respond(false, undefined, errorShape(resolved.response.code, resolved.response.message));
respondPendingApprovalLookupError({ respond, response: resolved.response });
return;
}
const { commandText, commandPreview } = resolveExecApprovalCommandDisplay(
@@ -272,107 +247,63 @@ export function createExecApprovalHandlers(
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
};
context.broadcast("exec.approval.requested", requestEvent, { dropIfSlow: true });
const hasExecApprovalClients = context.hasExecApprovalClients?.(client?.connId) ?? false;
const hasTurnSourceRoute = hasApprovalTurnSourceRoute({
turnSourceChannel: record.request.turnSourceChannel,
turnSourceAccountId: record.request.turnSourceAccountId,
});
let forwarded = false;
if (opts?.forwarder) {
try {
forwarded = await opts.forwarder.handleRequested(requestEvent);
} catch (err) {
context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`);
}
}
let deliveredToIosPush = false;
if (opts?.iosPushDelivery?.handleRequested) {
try {
deliveredToIosPush = await opts.iosPushDelivery.handleRequested(requestEvent);
} catch (err) {
context.logGateway?.error?.(`exec approvals: iOS push request failed: ${String(err)}`);
}
}
if (!hasExecApprovalClients && !forwarded && !hasTurnSourceRoute && !deliveredToIosPush) {
manager.expire(record.id, "no-approval-route");
respond(
true,
{
id: record.id,
decision: null,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
undefined,
);
return;
}
// Only send immediate "accepted" response when twoPhase is requested.
// This preserves single-response semantics for existing callers.
if (twoPhase) {
respond(
true,
{
status: "accepted",
id: record.id,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
undefined,
);
}
const decision = await decisionPromise;
if (decision === null) {
void opts?.iosPushDelivery?.handleExpired?.(requestEvent).catch((err) => {
context.logGateway?.error?.(`exec approvals: iOS push expire failed: ${String(err)}`);
});
}
// Send final response with decision for callers using expectFinal:true.
respond(
true,
{
id: record.id,
decision,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
await handlePendingApprovalRequest({
manager,
record,
decisionPromise,
respond,
context,
clientConnId: client?.connId,
requestEventName: "exec.approval.requested",
requestEvent,
twoPhase,
deliverRequest: () => {
const deliveryTasks: Array<Promise<boolean>> = [];
if (opts?.forwarder) {
deliveryTasks.push(
opts.forwarder.handleRequested(requestEvent).catch((err) => {
context.logGateway?.error?.(
`exec approvals: forward request failed: ${String(err)}`,
);
return false;
}),
);
}
if (opts?.iosPushDelivery?.handleRequested) {
deliveryTasks.push(
opts.iosPushDelivery.handleRequested(requestEvent).catch((err) => {
context.logGateway?.error?.(
`exec approvals: iOS push request failed: ${String(err)}`,
);
return false;
}),
);
}
if (deliveryTasks.length === 0) {
return false;
}
return (async () => {
let delivered = false;
for (const task of deliveryTasks) {
delivered = (await task) || delivered;
}
return delivered;
})();
},
undefined,
);
afterDecision: async (decision) => {
if (decision === null) {
await opts?.iosPushDelivery?.handleExpired?.(requestEvent);
}
},
afterDecisionErrorLabel: "exec approvals: iOS push expire failed",
});
},
"exec.approval.waitDecision": async ({ params, respond }) => {
const p = params as { id?: string };
const id = typeof p.id === "string" ? p.id.trim() : "";
if (!id) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id is required"));
return;
}
const decisionPromise = manager.awaitDecision(id);
if (!decisionPromise) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "approval expired or not found"),
);
return;
}
// Capture snapshot before await (entry may be deleted after grace period)
const snapshot = manager.getSnapshot(id);
const decision = await decisionPromise;
// Return decision (can be null on timeout) - let clients handle via askFallback
respond(
true,
{
id,
decision,
createdAtMs: snapshot?.createdAtMs,
expiresAtMs: snapshot?.expiresAtMs,
},
undefined,
);
await handleApprovalWaitDecision({
manager,
inputId: (params as { id?: string }).id,
respond,
});
},
"exec.approval.resolve": async ({ params, respond, client, context }) => {
if (!validateExecApprovalResolveParams(params)) {
@@ -389,70 +320,48 @@ export function createExecApprovalHandlers(
return;
}
const p = params as { id: string; decision: string };
const decision = p.decision as ExecApprovalDecision;
if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") {
if (!isApprovalDecision(p.decision)) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const resolved = resolvePendingApprovalRecord(manager, p.id);
if (!resolved.ok) {
if (resolved.response === "missing") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
return;
}
respond(false, undefined, errorShape(resolved.response.code, resolved.response.message));
return;
}
const approvalId = resolved.approvalId;
const snapshot = resolved.snapshot;
const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(snapshot?.request);
if (snapshot && !allowedDecisions.includes(decision)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"allow-always is unavailable because the effective policy requires approval every time",
{
details: APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS,
},
),
);
return;
}
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
const ok = manager.resolve(approvalId, decision, resolvedBy ?? null);
if (!ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
return;
}
const resolvedEvent: ExecApprovalResolved = {
id: approvalId,
decision,
resolvedBy,
ts: Date.now(),
request: snapshot?.request,
};
context.broadcast("exec.approval.resolved", resolvedEvent, { dropIfSlow: true });
void opts?.forwarder?.handleResolved(resolvedEvent).catch((err) => {
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
await handleApprovalResolve({
manager,
inputId: p.id,
decision: p.decision,
respond,
context,
client,
exposeAmbiguousPrefixError: true,
validateDecision: (snapshot) => {
const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(snapshot.request);
return allowedDecisions.includes(p.decision)
? null
: {
message:
"allow-always is unavailable because the effective policy requires approval every time",
details: APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS,
};
},
resolvedEventName: "exec.approval.resolved",
buildResolvedEvent: ({ approvalId, decision, resolvedBy, snapshot, nowMs }) =>
({
id: approvalId,
decision,
resolvedBy,
ts: nowMs,
request: snapshot.request,
}) satisfies ExecApprovalResolved,
forwardResolved: (resolvedEvent) => opts?.forwarder?.handleResolved(resolvedEvent),
forwardResolvedErrorLabel: "exec approvals: forward resolve failed",
extraResolvedHandlers: opts?.iosPushDelivery?.handleResolved
? [
{
run: (resolvedEvent) => opts.iosPushDelivery!.handleResolved!(resolvedEvent),
errorLabel: "exec approvals: iOS push resolve failed",
},
]
: undefined,
});
void opts?.iosPushDelivery?.handleResolved?.(resolvedEvent).catch((err) => {
context.logGateway?.error?.(`exec approvals: iOS push resolve failed: ${String(err)}`);
});
respond(true, { ok: true }, undefined);
},
};
}

View File

@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
@@ -15,12 +14,14 @@ import {
validatePluginApprovalRequestParams,
validatePluginApprovalResolveParams,
} from "../protocol/index.js";
import {
handleApprovalResolve,
handleApprovalWaitDecision,
handlePendingApprovalRequest,
isApprovalDecision,
} from "./approval-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
const APPROVAL_NOT_FOUND_DETAILS = {
reason: ErrorCodes.APPROVAL_NOT_FOUND,
} as const;
export function createPluginApprovalHandlers(
manager: ExecApprovalManager<PluginApprovalRequestPayload>,
opts?: { forwarder?: ExecApprovalForwarder },
@@ -96,105 +97,41 @@ export function createPluginApprovalHandlers(
return;
}
context.broadcast(
"plugin.approval.requested",
{
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
{ dropIfSlow: true },
);
const requestEvent = {
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
};
let forwarded = false;
if (opts?.forwarder?.handlePluginApprovalRequested) {
try {
forwarded = await opts.forwarder.handlePluginApprovalRequested({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
await handlePendingApprovalRequest({
manager,
record,
decisionPromise,
respond,
context,
clientConnId: client?.connId,
requestEventName: "plugin.approval.requested",
requestEvent,
twoPhase,
deliverRequest: () => {
if (!opts?.forwarder?.handlePluginApprovalRequested) {
return false;
}
return opts.forwarder.handlePluginApprovalRequested(requestEvent).catch((err) => {
context.logGateway?.error?.(`plugin approvals: forward request failed: ${String(err)}`);
return false;
});
} catch (err) {
context.logGateway?.error?.(`plugin approvals: forward request failed: ${String(err)}`);
}
}
const hasApprovalClients = context.hasExecApprovalClients?.(client?.connId) ?? false;
const hasTurnSourceRoute = hasApprovalTurnSourceRoute({
turnSourceChannel: record.request.turnSourceChannel,
turnSourceAccountId: record.request.turnSourceAccountId,
});
if (!hasApprovalClients && !forwarded && !hasTurnSourceRoute) {
manager.expire(record.id, "no-approval-route");
respond(
true,
{
id: record.id,
decision: null,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
undefined,
);
return;
}
if (twoPhase) {
respond(
true,
{
status: "accepted",
id: record.id,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
undefined,
);
}
const decision = await decisionPromise;
respond(
true,
{
id: record.id,
decision,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
undefined,
);
});
},
"plugin.approval.waitDecision": async ({ params, respond }) => {
const p = params as { id?: string };
const id = typeof p.id === "string" ? p.id.trim() : "";
if (!id) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id is required"));
return;
}
const decisionPromise = manager.awaitDecision(id);
if (!decisionPromise) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "approval expired or not found"),
);
return;
}
const snapshot = manager.getSnapshot(id);
const decision = await decisionPromise;
respond(
true,
{
id,
decision,
createdAtMs: snapshot?.createdAtMs,
expiresAtMs: snapshot?.expiresAtMs,
},
undefined,
);
await handleApprovalWaitDecision({
manager,
inputId: (params as { id?: string }).id,
respond,
});
},
"plugin.approval.resolve": async ({ params, respond, client, context }) => {
@@ -212,63 +149,30 @@ export function createPluginApprovalHandlers(
return;
}
const p = params as { id: string; decision: string };
const decision = p.decision as ExecApprovalDecision;
if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") {
if (!isApprovalDecision(p.decision)) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const resolvedId = manager.lookupPendingId(p.id);
if (resolvedId.kind === "none" || resolvedId.kind === "ambiguous") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
return;
}
const approvalId = resolvedId.id;
const snapshot = manager.getSnapshot(approvalId);
if (!snapshot || snapshot.resolvedAtMs !== undefined) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
return;
}
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
const ok = manager.resolve(approvalId, decision, resolvedBy ?? null);
if (!ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
return;
}
context.broadcast(
"plugin.approval.resolved",
{ id: approvalId, decision, resolvedBy, ts: Date.now(), request: snapshot?.request },
{ dropIfSlow: true },
);
void opts?.forwarder
?.handlePluginApprovalResolved?.({
await handleApprovalResolve({
manager,
inputId: p.id,
decision: p.decision,
respond,
context,
client,
exposeAmbiguousPrefixError: false,
resolvedEventName: "plugin.approval.resolved",
buildResolvedEvent: ({ approvalId, decision, resolvedBy, snapshot, nowMs }) => ({
id: approvalId,
decision,
resolvedBy,
ts: Date.now(),
request: snapshot?.request,
})
.catch((err) => {
context.logGateway?.error?.(`plugin approvals: forward resolve failed: ${String(err)}`);
});
respond(true, { ok: true }, undefined);
ts: nowMs,
request: snapshot.request,
}),
forwardResolved: (resolvedEvent) =>
opts?.forwarder?.handlePluginApprovalResolved?.(resolvedEvent),
forwardResolvedErrorLabel: "plugin approvals: forward resolve failed",
});
},
};
}

View File

@@ -414,6 +414,39 @@ describe("gateway send mirroring", () => {
);
});
it("includes optional poll delivery identifiers in the gateway payload", async () => {
mocks.sendPoll.mockResolvedValue({
messageId: "poll-rich",
channelId: "C123",
conversationId: "conv-1",
toJid: "jid-1",
pollId: "poll-meta-1",
});
const { respond } = await runPoll({
to: "channel:C1",
question: "Q?",
options: ["A", "B"],
channel: "slack",
idempotencyKey: "idem-poll-rich",
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
runId: "idem-poll-rich",
messageId: "poll-rich",
channel: "slack",
channelId: "C123",
conversationId: "conv-1",
toJid: "jid-1",
pollId: "poll-meta-1",
}),
undefined,
expect.objectContaining({ channel: "slack" }),
);
});
it("auto-picks the single configured channel for poll", async () => {
const { respond } = await runPoll({
to: "x",

View File

@@ -92,6 +92,88 @@ async function resolveRequestedChannel(params: {
return { cfg, channel };
}
function resolveGatewayOutboundTarget(params: {
channel: string;
to: string;
cfg: ReturnType<typeof loadConfig>;
accountId?: string;
}):
| {
ok: true;
to: string;
}
| {
ok: false;
error: ReturnType<typeof errorShape>;
} {
const resolved = resolveOutboundTarget({
channel: params.channel,
to: params.to,
cfg: params.cfg,
accountId: params.accountId,
mode: "explicit",
});
if (!resolved.ok) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)),
};
}
return { ok: true, to: resolved.to };
}
function buildGatewayDeliveryPayload(params: {
runId: string;
channel: string;
result: Record<string, unknown>;
}): Record<string, unknown> {
const payload: Record<string, unknown> = {
runId: params.runId,
messageId: params.result.messageId,
channel: params.channel,
};
if ("chatId" in params.result) {
payload.chatId = params.result.chatId;
}
if ("channelId" in params.result) {
payload.channelId = params.result.channelId;
}
if ("toJid" in params.result) {
payload.toJid = params.result.toJid;
}
if ("conversationId" in params.result) {
payload.conversationId = params.result.conversationId;
}
if ("pollId" in params.result) {
payload.pollId = params.result.pollId;
}
return payload;
}
function cacheGatewayDedupeSuccess(params: {
context: GatewayRequestContext;
dedupeKey: string;
payload: Record<string, unknown>;
}) {
params.context.dedupe.set(params.dedupeKey, {
ts: Date.now(),
ok: true,
payload: params.payload,
});
}
function cacheGatewayDedupeFailure(params: {
context: GatewayRequestContext;
dedupeKey: string;
error: ReturnType<typeof errorShape>;
}) {
params.context.dedupe.set(params.dedupeKey, {
ts: Date.now(),
ok: false,
error: params.error,
});
}
export const sendHandlers: GatewayRequestHandlers = {
send: async ({ params, respond, context, client }) => {
const p = params;
@@ -186,27 +268,26 @@ export const sendHandlers: GatewayRequestHandlers = {
const work = (async (): Promise<InflightResult> => {
try {
const resolved = resolveOutboundTarget({
const resolvedTarget = resolveGatewayOutboundTarget({
channel: outboundChannel,
to,
cfg,
accountId,
mode: "explicit",
});
if (!resolved.ok) {
if (!resolvedTarget.ok) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)),
error: resolvedTarget.error,
meta: { channel },
};
}
const idLikeTarget = await maybeResolveIdLikeTarget({
cfg,
channel,
input: resolved.to,
input: resolvedTarget.to,
accountId,
});
const deliveryTarget = idLikeTarget?.to ?? resolved.to;
const deliveryTarget = idLikeTarget?.to ?? resolvedTarget.to;
const outboundDeps = context.deps ? createOutboundSendDeps(context.deps) : undefined;
const mirrorPayloads = normalizeReplyPayloadsForDelivery([
{ text: message, mediaUrl, mediaUrls },
@@ -290,28 +371,8 @@ export const sendHandlers: GatewayRequestHandlers = {
if (!result) {
throw new Error("No delivery result");
}
const payload: Record<string, unknown> = {
runId: idem,
messageId: result.messageId,
channel,
};
if ("chatId" in result) {
payload.chatId = result.chatId;
}
if ("channelId" in result) {
payload.channelId = result.channelId;
}
if ("toJid" in result) {
payload.toJid = result.toJid;
}
if ("conversationId" in result) {
payload.conversationId = result.conversationId;
}
context.dedupe.set(dedupeKey, {
ts: Date.now(),
ok: true,
payload,
});
const payload = buildGatewayDeliveryPayload({ runId: idem, channel, result });
cacheGatewayDedupeSuccess({ context, dedupeKey, payload });
return {
ok: true,
payload,
@@ -319,11 +380,7 @@ export const sendHandlers: GatewayRequestHandlers = {
};
} catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
context.dedupe.set(dedupeKey, {
ts: Date.now(),
ok: false,
error,
});
cacheGatewayDedupeFailure({ context, dedupeKey, error });
return { ok: false, error, meta: { channel, error: formatForLog(err) } };
}
})();
@@ -429,15 +486,14 @@ export const sendHandlers: GatewayRequestHandlers = {
);
return;
}
const resolved = resolveOutboundTarget({
const resolvedTarget = resolveGatewayOutboundTarget({
channel: channel,
to,
cfg,
accountId,
mode: "explicit",
});
if (!resolved.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)));
if (!resolvedTarget.ok) {
respond(false, undefined, resolvedTarget.error);
return;
}
const normalized = outbound.pollMaxOptions
@@ -445,7 +501,7 @@ export const sendHandlers: GatewayRequestHandlers = {
: normalizePollInput(poll);
const result = await outbound.sendPoll({
cfg,
to: resolved.to,
to: resolvedTarget.to,
poll: normalized,
accountId,
threadId,
@@ -453,34 +509,18 @@ export const sendHandlers: GatewayRequestHandlers = {
isAnonymous: request.isAnonymous,
gatewayClientScopes: client?.connect?.scopes ?? [],
});
const payload: Record<string, unknown> = {
runId: idem,
messageId: result.messageId,
channel,
};
if (result.toJid) {
payload.toJid = result.toJid;
}
if (result.channelId) {
payload.channelId = result.channelId;
}
if (result.conversationId) {
payload.conversationId = result.conversationId;
}
if (result.pollId) {
payload.pollId = result.pollId;
}
context.dedupe.set(`poll:${idem}`, {
ts: Date.now(),
ok: true,
const payload = buildGatewayDeliveryPayload({ runId: idem, channel, result });
cacheGatewayDedupeSuccess({
context,
dedupeKey: `poll:${idem}`,
payload,
});
respond(true, payload, undefined, { channel });
} catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
context.dedupe.set(`poll:${idem}`, {
ts: Date.now(),
ok: false,
cacheGatewayDedupeFailure({
context,
dedupeKey: `poll:${idem}`,
error,
});
respond(false, undefined, error, {

View File

@@ -5,7 +5,11 @@ import type {
OpenClawConfig,
} from "../config/config.js";
import { replaceConfigFile } from "../config/config.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import {
hasConfiguredGatewayAuthSecretInput,
resolveGatewayPasswordSecretRefValue,
resolveGatewayTokenSecretRefValue,
} from "./auth-config-utils.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "./auth-mode-policy.js";
import { resolveGatewayAuth, type ResolvedGatewayAuth } from "./auth.js";
import {
@@ -13,7 +17,6 @@ import {
hasGatewayTokenEnvCandidate,
readGatewayTokenEnv,
} from "./credentials.js";
import { resolveRequiredConfiguredSecretRefInputString } from "./resolve-configured-secret-input-string.js";
export function mergeGatewayAuthConfig(
base?: GatewayAuthConfig,
@@ -111,7 +114,7 @@ function hasGatewayTokenCandidate(params: {
) {
return true;
}
return hasConfiguredSecretInput(params.cfg.gateway?.auth?.token, params.cfg.secrets?.defaults);
return hasConfiguredGatewayAuthSecretInput(params.cfg, "gateway.auth.token");
}
function hasGatewayTokenOverrideCandidate(params: { authOverride?: GatewayAuthConfig }): boolean {
@@ -133,88 +136,6 @@ function hasGatewayPasswordOverrideCandidate(params: {
);
}
function shouldResolveGatewayTokenSecretRef(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
authOverride?: GatewayAuthConfig;
}): boolean {
if (hasGatewayTokenOverrideCandidate({ authOverride: params.authOverride })) {
return false;
}
if (hasGatewayTokenEnvCandidate(params.env)) {
return false;
}
const explicitMode = params.authOverride?.mode ?? params.cfg.gateway?.auth?.mode;
if (explicitMode === "token") {
return true;
}
if (explicitMode === "password" || explicitMode === "none" || explicitMode === "trusted-proxy") {
return false;
}
if (hasGatewayPasswordOverrideCandidate(params)) {
return false;
}
return !hasConfiguredSecretInput(
params.cfg.gateway?.auth?.password,
params.cfg.secrets?.defaults,
);
}
async function resolveGatewayTokenSecretRef(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
authOverride?: GatewayAuthConfig,
): Promise<string | undefined> {
if (!shouldResolveGatewayTokenSecretRef({ cfg, env, authOverride })) {
return undefined;
}
return await resolveRequiredConfiguredSecretRefInputString({
config: cfg,
env,
value: cfg.gateway?.auth?.token,
path: "gateway.auth.token",
});
}
function shouldResolveGatewayPasswordSecretRef(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
authOverride?: GatewayAuthConfig;
}): boolean {
if (hasGatewayPasswordOverrideCandidate(params)) {
return false;
}
const explicitMode = params.authOverride?.mode ?? params.cfg.gateway?.auth?.mode;
if (explicitMode === "password") {
return true;
}
if (explicitMode === "token" || explicitMode === "none" || explicitMode === "trusted-proxy") {
return false;
}
if (hasGatewayTokenCandidate(params)) {
return false;
}
return true;
}
async function resolveGatewayPasswordSecretRef(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
authOverride?: GatewayAuthConfig,
): Promise<string | undefined> {
if (!shouldResolveGatewayPasswordSecretRef({ cfg, env, authOverride })) {
return undefined;
}
return await resolveRequiredConfiguredSecretRefInputString({
config: cfg,
env,
value: cfg.gateway?.auth?.password,
path: "gateway.auth.password",
});
}
export async function ensureGatewayStartupAuth(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@@ -231,9 +152,33 @@ export async function ensureGatewayStartupAuth(params: {
assertExplicitGatewayAuthModeWhenBothConfigured(params.cfg);
const env = params.env ?? process.env;
const persistRequested = params.persist === true;
const explicitMode = params.authOverride?.mode ?? params.cfg.gateway?.auth?.mode;
const [resolvedTokenRefValue, resolvedPasswordRefValue] = await Promise.all([
resolveGatewayTokenSecretRef(params.cfg, env, params.authOverride),
resolveGatewayPasswordSecretRef(params.cfg, env, params.authOverride),
resolveGatewayTokenSecretRefValue({
cfg: params.cfg,
env,
mode: explicitMode,
hasTokenCandidate:
hasGatewayTokenOverrideCandidate({ authOverride: params.authOverride }) ||
hasGatewayTokenEnvCandidate(env),
hasPasswordCandidate:
hasGatewayPasswordOverrideCandidate({ env, authOverride: params.authOverride }) ||
hasConfiguredGatewayAuthSecretInput(params.cfg, "gateway.auth.password"),
}),
resolveGatewayPasswordSecretRefValue({
cfg: params.cfg,
env,
mode: explicitMode,
hasPasswordCandidate: hasGatewayPasswordOverrideCandidate({
env,
authOverride: params.authOverride,
}),
hasTokenCandidate: hasGatewayTokenCandidate({
cfg: params.cfg,
env,
authOverride: params.authOverride,
}),
}),
]);
const authOverride: GatewayAuthConfig | undefined =
params.authOverride || resolvedTokenRefValue || resolvedPasswordRefValue

View File

@@ -104,6 +104,15 @@ type ApprovalStrategy<
) => ReplyPayload;
};
type ApprovalRouteRequestFields = {
agentId?: string | null;
sessionKey?: string | null;
turnSourceChannel?: string | null;
turnSourceTo?: string | null;
turnSourceAccountId?: string | null;
turnSourceThreadId?: string | number | null;
};
export type ExecApprovalForwarder = {
handleRequested: (request: ExecApprovalRequest) => Promise<boolean>;
handleResolved: (resolved: ExecApprovalResolved) => Promise<void>;
@@ -278,6 +287,22 @@ function normalizeTurnSourceChannel(value?: string | null): DeliverableMessageCh
return normalized && isDeliverableMessageChannel(normalized) ? normalized : undefined;
}
function extractApprovalRouteRequest(
request: ApprovalRouteRequestFields | null | undefined,
): ApprovalRouteRequest | null {
if (!request) {
return null;
}
return {
agentId: request.agentId ?? null,
sessionKey: request.sessionKey ?? null,
turnSourceChannel: request.turnSourceChannel ?? null,
turnSourceTo: request.turnSourceTo ?? null,
turnSourceAccountId: request.turnSourceAccountId ?? null,
turnSourceThreadId: request.turnSourceThreadId ?? null,
};
}
function defaultResolveSessionTarget(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
@@ -341,33 +366,42 @@ async function deliverToTargets(params: {
await Promise.allSettled(deliveries);
}
function buildApprovalRenderPayload<TParams>(params: {
target: ForwardTarget;
renderParams: TParams;
resolveRenderer: (
adapter: ReturnType<typeof resolveChannelApprovalAdapter> | undefined,
) => ((params: TParams) => ReplyPayload | null) | undefined;
buildFallback: () => ReplyPayload;
}): ReplyPayload {
const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel;
const adapterPayload = channel
? params.resolveRenderer(resolveChannelApprovalAdapter(getChannelPlugin(channel)))?.(
params.renderParams,
)
: null;
return adapterPayload ?? params.buildFallback();
}
function buildExecPendingPayload(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
target: ForwardTarget;
nowMs: number;
}): ReplyPayload {
const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel;
const pluginPayload = channel
? resolveChannelApprovalAdapter(getChannelPlugin(channel))?.render?.exec?.buildPendingPayload?.(
{
cfg: params.cfg,
request: params.request,
target: params.target,
nowMs: params.nowMs,
},
)
: null;
if (pluginPayload) {
return pluginPayload;
}
return buildApprovalPendingReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
text: buildRequestMessage(params.request, params.nowMs),
agentId: params.request.request.agentId ?? null,
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request),
sessionKey: params.request.request.sessionKey ?? null,
return buildApprovalRenderPayload({
target: params.target,
renderParams: params,
resolveRenderer: (adapter) => adapter?.render?.exec?.buildPendingPayload,
buildFallback: () =>
buildApprovalPendingReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
text: buildRequestMessage(params.request, params.nowMs),
agentId: params.request.request.agentId ?? null,
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request),
sessionKey: params.request.request.sessionKey ?? null,
}),
});
}
@@ -376,23 +410,16 @@ function buildExecResolvedPayload(params: {
resolved: ExecApprovalResolved;
target: ForwardTarget;
}): ReplyPayload {
const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel;
const pluginPayload = channel
? resolveChannelApprovalAdapter(
getChannelPlugin(channel),
)?.render?.exec?.buildResolvedPayload?.({
cfg: params.cfg,
resolved: params.resolved,
target: params.target,
})
: null;
if (pluginPayload) {
return pluginPayload;
}
return buildApprovalResolvedReplyPayload({
approvalId: params.resolved.id,
approvalSlug: params.resolved.id.slice(0, 8),
text: buildResolvedMessage(params.resolved),
return buildApprovalRenderPayload({
target: params.target,
renderParams: params,
resolveRenderer: (adapter) => adapter?.render?.exec?.buildResolvedPayload,
buildFallback: () =>
buildApprovalResolvedReplyPayload({
approvalId: params.resolved.id,
approvalSlug: params.resolved.id.slice(0, 8),
text: buildResolvedMessage(params.resolved),
}),
});
}
@@ -402,24 +429,16 @@ function buildPluginPendingPayload(params: {
target: ForwardTarget;
nowMs: number;
}): ReplyPayload {
const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel;
const adapterPayload = channel
? resolveChannelApprovalAdapter(
getChannelPlugin(channel),
)?.render?.plugin?.buildPendingPayload?.({
cfg: params.cfg,
return buildApprovalRenderPayload({
target: params.target,
renderParams: params,
resolveRenderer: (adapter) => adapter?.render?.plugin?.buildPendingPayload,
buildFallback: () =>
buildPluginApprovalPendingReplyPayload({
request: params.request,
target: params.target,
nowMs: params.nowMs,
})
: null;
if (adapterPayload) {
return adapterPayload;
}
return buildPluginApprovalPendingReplyPayload({
request: params.request,
nowMs: params.nowMs,
text: buildPluginApprovalRequestMessage(params.request, params.nowMs),
text: buildPluginApprovalRequestMessage(params.request, params.nowMs),
}),
});
}
@@ -428,21 +447,14 @@ function buildPluginResolvedPayload(params: {
resolved: PluginApprovalResolved;
target: ForwardTarget;
}): ReplyPayload {
const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel;
const adapterPayload = channel
? resolveChannelApprovalAdapter(
getChannelPlugin(channel),
)?.render?.plugin?.buildResolvedPayload?.({
cfg: params.cfg,
return buildApprovalRenderPayload({
target: params.target,
renderParams: params,
resolveRenderer: (adapter) => adapter?.render?.plugin?.buildResolvedPayload,
buildFallback: () =>
buildPluginApprovalResolvedReplyPayload({
resolved: params.resolved,
target: params.target,
})
: null;
if (adapterPayload) {
return adapterPayload;
}
return buildPluginApprovalResolvedReplyPayload({
resolved: params.resolved,
}),
});
}
@@ -659,31 +671,37 @@ function createApprovalHandlers<
return { handleRequested, handleResolved, stop };
}
const execApprovalStrategy: ApprovalStrategy<ExecApprovalRequest, ExecApprovalResolved> = {
function createApprovalStrategy<
TRequest extends { id: string; request: ApprovalRouteRequestFields; expiresAtMs: number },
TResolved extends { id: string; request?: ApprovalRouteRequestFields | null },
>(params: {
kind: ApprovalKind;
config: (cfg: OpenClawConfig) => ExecApprovalForwardingConfig | undefined;
buildExpiredText: (request: TRequest) => string;
buildPendingPayload: (
params: ApprovalPendingRenderContext<TRequest, ApprovalRouteRequest>,
) => ReplyPayload;
buildResolvedPayload: (
params: ApprovalResolvedRenderContext<TResolved, ApprovalRouteRequest>,
) => ReplyPayload;
}): ApprovalStrategy<TRequest, TResolved> {
return {
kind: params.kind,
config: params.config,
getRequestId: (request) => request.id,
getResolvedId: (resolved) => resolved.id,
getExpiresAtMs: (request) => request.expiresAtMs,
getRouteRequestFromRequest: (request) => extractApprovalRouteRequest(request.request) ?? {},
getRouteRequestFromResolved: (resolved) => extractApprovalRouteRequest(resolved.request),
buildExpiredText: params.buildExpiredText,
buildPendingPayload: params.buildPendingPayload,
buildResolvedPayload: params.buildResolvedPayload,
};
}
const execApprovalStrategy = createApprovalStrategy<ExecApprovalRequest, ExecApprovalResolved>({
kind: "exec",
config: (cfg) => cfg.approvals?.exec,
getRequestId: (request) => request.id,
getResolvedId: (resolved) => resolved.id,
getExpiresAtMs: (request) => request.expiresAtMs,
getRouteRequestFromRequest: (request) => ({
agentId: request.request.agentId ?? null,
sessionKey: request.request.sessionKey ?? null,
turnSourceChannel: request.request.turnSourceChannel ?? null,
turnSourceTo: request.request.turnSourceTo ?? null,
turnSourceAccountId: request.request.turnSourceAccountId ?? null,
turnSourceThreadId: request.request.turnSourceThreadId ?? null,
}),
getRouteRequestFromResolved: (resolved) =>
resolved.request
? {
agentId: resolved.request.agentId ?? null,
sessionKey: resolved.request.sessionKey ?? null,
turnSourceChannel: resolved.request.turnSourceChannel ?? null,
turnSourceTo: resolved.request.turnSourceTo ?? null,
turnSourceAccountId: resolved.request.turnSourceAccountId ?? null,
turnSourceThreadId: resolved.request.turnSourceThreadId ?? null,
}
: null,
buildExpiredText: buildExpiredMessage,
buildPendingPayload: ({ cfg, request, target, nowMs }) =>
buildExecPendingPayload({
@@ -698,33 +716,14 @@ const execApprovalStrategy: ApprovalStrategy<ExecApprovalRequest, ExecApprovalRe
resolved,
target,
}),
};
});
const pluginApprovalStrategy: ApprovalStrategy<PluginApprovalRequest, PluginApprovalResolved> = {
const pluginApprovalStrategy = createApprovalStrategy<
PluginApprovalRequest,
PluginApprovalResolved
>({
kind: "plugin",
config: (cfg) => cfg.approvals?.plugin,
getRequestId: (request) => request.id,
getResolvedId: (resolved) => resolved.id,
getExpiresAtMs: (request) => request.expiresAtMs,
getRouteRequestFromRequest: (request) => ({
agentId: request.request.agentId ?? null,
sessionKey: request.request.sessionKey ?? null,
turnSourceChannel: request.request.turnSourceChannel ?? null,
turnSourceTo: request.request.turnSourceTo ?? null,
turnSourceAccountId: request.request.turnSourceAccountId ?? null,
turnSourceThreadId: request.request.turnSourceThreadId ?? null,
}),
getRouteRequestFromResolved: (resolved) =>
resolved.request
? {
agentId: resolved.request.agentId ?? null,
sessionKey: resolved.request.sessionKey ?? null,
turnSourceChannel: resolved.request.turnSourceChannel ?? null,
turnSourceTo: resolved.request.turnSourceTo ?? null,
turnSourceAccountId: resolved.request.turnSourceAccountId ?? null,
turnSourceThreadId: resolved.request.turnSourceThreadId ?? null,
}
: null,
buildExpiredText: buildPluginApprovalExpiredMessage,
buildPendingPayload: ({ cfg, request, target, nowMs }) =>
buildPluginPendingPayload({
@@ -739,7 +738,7 @@ const pluginApprovalStrategy: ApprovalStrategy<PluginApprovalRequest, PluginAppr
resolved,
target,
}),
};
});
export function createExecApprovalForwarder(
deps: ExecApprovalForwarderDeps = {},

View File

@@ -1,9 +1,8 @@
import { randomUUID } from "node:crypto";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { OpenClawConfig } from "../config/config.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { resolveGatewayClientBootstrap } from "../gateway/client-bootstrap.js";
import { GatewayClient } from "../gateway/client.js";
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
import { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../gateway/method-scopes.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import { extractFirstTextBlock } from "../shared/chat-message-content.js";
@@ -84,25 +83,14 @@ export class OpenClawChannelBridge {
return;
}
this.started = true;
const connection = buildGatewayConnectionDetails({
config: this.cfg,
url: this.params.gatewayUrl,
});
const gatewayUrlOverrideSource =
connection.urlSource === "cli --url"
? "cli"
: connection.urlSource === "env OPENCLAW_GATEWAY_URL"
? "env"
: undefined;
const creds = await resolveGatewayConnectionAuth({
const bootstrap = await resolveGatewayClientBootstrap({
config: this.cfg,
gatewayUrl: this.params.gatewayUrl,
explicitAuth: {
token: this.params.gatewayToken,
password: this.params.gatewayPassword,
},
env: process.env,
urlOverride: gatewayUrlOverrideSource ? connection.url : undefined,
urlOverrideSource: gatewayUrlOverrideSource,
});
if (this.closed) {
this.resolveReadyOnce();
@@ -110,9 +98,9 @@ export class OpenClawChannelBridge {
}
this.gateway = new GatewayClient({
url: connection.url,
token: creds.token,
password: creds.password,
url: bootstrap.url,
token: bootstrap.auth.token,
password: bootstrap.auth.password,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "OpenClaw MCP",
clientVersion: VERSION,

View File

@@ -1,14 +1,10 @@
import os from "node:os";
import { resolveGatewayPort } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.js";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
resolveSecretInputRef,
} from "../config/types.secrets.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
import { materializeGatewayAuthSecretRefs } from "../gateway/auth-config-utils.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
import { isLoopbackHost, isSecureWebSocketUrl } from "../gateway/net.js";
import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js";
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
import {
pickMatchingExternalInterfaceAddress,
@@ -268,94 +264,6 @@ function resolvePairingSetupAuthLabel(
return { error: "Gateway auth is not configured (no token or password)." };
}
async function resolveGatewayTokenSecretRef(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): Promise<OpenClawConfig> {
const hasTokenEnvCandidate = Boolean(resolveGatewayTokenFromEnv(env));
if (hasTokenEnvCandidate) {
return cfg;
}
const mode = cfg.gateway?.auth?.mode;
if (mode === "password" || mode === "none" || mode === "trusted-proxy") {
return cfg;
}
if (mode !== "token") {
const hasPasswordEnvCandidate = Boolean(env.OPENCLAW_GATEWAY_PASSWORD?.trim());
if (hasPasswordEnvCandidate) {
return cfg;
}
}
const token = await resolveRequiredConfiguredSecretRefInputString({
config: cfg,
env,
value: cfg.gateway?.auth?.token,
path: "gateway.auth.token",
});
if (!token) {
return cfg;
}
return {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
token,
},
},
};
}
async function resolveGatewayPasswordSecretRef(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): Promise<OpenClawConfig> {
const hasPasswordEnvCandidate = Boolean(resolveGatewayPasswordFromEnv(env));
if (hasPasswordEnvCandidate) {
return cfg;
}
const mode = cfg.gateway?.auth?.mode;
if (mode === "token" || mode === "none" || mode === "trusted-proxy") {
return cfg;
}
if (mode !== "password") {
const hasTokenCandidate =
Boolean(resolveGatewayTokenFromEnv(env)) ||
hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults);
if (hasTokenCandidate) {
return cfg;
}
}
const password = await resolveRequiredConfiguredSecretRefInputString({
config: cfg,
env,
value: cfg.gateway?.auth?.password,
path: "gateway.auth.password",
});
if (!password) {
return cfg;
}
return {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
password,
},
},
};
}
async function materializePairingSetupAuthConfig(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): Promise<OpenClawConfig> {
const cfgWithToken = await resolveGatewayTokenSecretRef(cfg, env);
return await resolveGatewayPasswordSecretRef(cfgWithToken, env);
}
async function resolveGatewayUrl(
cfg: OpenClawConfig,
opts: {
@@ -430,7 +338,13 @@ export async function resolvePairingSetupFromConfig(
): Promise<PairingSetupResolution> {
assertExplicitGatewayAuthModeWhenBothConfigured(cfg);
const env = options.env ?? process.env;
const cfgForAuth = await materializePairingSetupAuthConfig(cfg, env);
const cfgForAuth = await materializeGatewayAuthSecretRefs({
cfg,
env,
mode: cfg.gateway?.auth?.mode,
hasTokenCandidate: Boolean(resolveGatewayTokenFromEnv(env)),
hasPasswordCandidate: Boolean(resolveGatewayPasswordFromEnv(env)),
});
const authLabel = resolvePairingSetupAuthLabel(cfgForAuth, env);
if (authLabel.error) {
return { ok: false, error: authLabel.error };

View File

@@ -128,6 +128,7 @@ let gatewayProbeDepsPromise:
| Promise<{
buildGatewayConnectionDetails: typeof import("../gateway/call.js").buildGatewayConnectionDetails;
resolveGatewayProbeAuthSafe: typeof import("../gateway/probe-auth.js").resolveGatewayProbeAuthSafe;
resolveGatewayProbeTarget: typeof import("../gateway/probe-auth.js").resolveGatewayProbeTarget;
probeGateway: typeof import("../gateway/probe.js").probeGateway;
}>
| undefined;
@@ -171,6 +172,7 @@ async function loadGatewayProbeDeps() {
]).then(([callModule, probeAuthModule, probeModule]) => ({
buildGatewayConnectionDetails: callModule.buildGatewayConnectionDetails,
resolveGatewayProbeAuthSafe: probeAuthModule.resolveGatewayProbeAuthSafe,
resolveGatewayProbeTarget: probeAuthModule.resolveGatewayProbeTarget,
probeGateway: probeModule.probeGateway,
}));
return await gatewayProbeDepsPromise;
@@ -1214,29 +1216,18 @@ async function maybeProbeGateway(params: {
deep: SecurityAuditReport["deep"];
authWarning?: string;
}> {
const { buildGatewayConnectionDetails, resolveGatewayProbeAuthSafe } =
const { buildGatewayConnectionDetails, resolveGatewayProbeAuthSafe, resolveGatewayProbeTarget } =
await loadGatewayProbeDeps();
const connection = buildGatewayConnectionDetails({ config: params.cfg });
const url = connection.url;
const isRemoteMode = params.cfg.gateway?.mode === "remote";
const remoteUrlRaw =
typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url.trim() : "";
const remoteUrlMissing = isRemoteMode && !remoteUrlRaw;
const probeTarget = resolveGatewayProbeTarget(params.cfg);
const authResolution =
!isRemoteMode || remoteUrlMissing
? resolveGatewayProbeAuthSafe({
cfg: params.cfg,
env: params.env,
mode: "local",
explicitAuth: params.explicitAuth,
})
: resolveGatewayProbeAuthSafe({
cfg: params.cfg,
env: params.env,
mode: "remote",
explicitAuth: params.explicitAuth,
});
const authResolution = resolveGatewayProbeAuthSafe({
cfg: params.cfg,
env: params.env,
mode: probeTarget.mode,
explicitAuth: params.explicitAuth,
});
const res = await params
.probe({ url, auth: authResolution.auth, timeoutMs: params.timeoutMs })
.catch((err) => ({

View File

@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { loadConfig } from "../config/config.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
import { resolveGatewayInteractiveSurfaceAuth } from "../gateway/auth-surface-resolution.js";
import {
buildGatewayConnectionDetails,
ensureExplicitGatewayAuth,
@@ -17,7 +17,6 @@ import {
type SessionsPatchResult,
type SessionsPatchParams,
} from "../gateway/protocol/index.js";
import { resolveConfiguredSecretInputString } from "../gateway/resolve-configured-secret-input-string.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { VERSION } from "../version.js";
import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js";
@@ -50,14 +49,6 @@ type ResolvedGatewayConnection = {
allowInsecureLocalOperatorUi?: boolean;
};
function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function throwGatewayAuthResolutionError(reason: string): never {
throw new Error(
[
@@ -277,9 +268,6 @@ export async function resolveGatewayConnection(
const env = process.env;
const gatewayAuthMode = config.gateway?.auth?.mode;
const isRemoteMode = config.gateway?.mode === "remote";
const remote = config.gateway?.remote;
const envToken = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN);
const envPassword = trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD);
const urlOverride =
typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined;
@@ -315,41 +303,34 @@ export async function resolveGatewayConnection(
}
if (isRemoteMode) {
const remoteToken = explicitAuth.token
? { value: explicitAuth.token }
: await resolveConfiguredSecretInputString({
value: remote?.token,
path: "gateway.remote.token",
env,
config,
});
const remotePassword =
explicitAuth.password || envPassword
? { value: explicitAuth.password ?? envPassword }
: await resolveConfiguredSecretInputString({
value: remote?.password,
path: "gateway.remote.password",
env,
config,
});
const token = explicitAuth.token ?? remoteToken.value;
const password = explicitAuth.password ?? envPassword ?? remotePassword.value;
if (!token && !password) {
throwGatewayAuthResolutionError(
remoteToken.unresolvedRefReason ??
remotePassword.unresolvedRefReason ??
"Missing gateway auth credentials.",
);
const resolved = await resolveGatewayInteractiveSurfaceAuth({
config,
env,
explicitAuth,
surface: "remote",
});
if (resolved.failureReason) {
throwGatewayAuthResolutionError(resolved.failureReason);
}
return { url, token, password, allowInsecureLocalOperatorUi: false };
return {
url,
token: resolved.token,
password: resolved.password,
allowInsecureLocalOperatorUi: false,
};
}
if (gatewayAuthMode === "none" || gatewayAuthMode === "trusted-proxy") {
const resolved = await resolveGatewayInteractiveSurfaceAuth({
config,
env,
explicitAuth,
surface: "local",
});
return {
url,
token: explicitAuth.token ?? envToken,
password: explicitAuth.password ?? envPassword,
token: resolved.token,
password: resolved.password,
allowInsecureLocalOperatorUi,
};
}
@@ -360,93 +341,19 @@ export async function resolveGatewayConnection(
throwGatewayAuthResolutionError(err instanceof Error ? err.message : String(err));
}
const defaults = config.secrets?.defaults;
const hasConfiguredToken = hasConfiguredSecretInput(config.gateway?.auth?.token, defaults);
const hasConfiguredPassword = hasConfiguredSecretInput(config.gateway?.auth?.password, defaults);
if (gatewayAuthMode === "password") {
const localPassword =
explicitAuth.password || envPassword
? { value: explicitAuth.password ?? envPassword }
: await resolveConfiguredSecretInputString({
value: config.gateway?.auth?.password,
path: "gateway.auth.password",
env,
config,
});
const password = explicitAuth.password ?? envPassword ?? localPassword.value;
if (!password) {
throwGatewayAuthResolutionError(
localPassword.unresolvedRefReason ?? "Missing gateway auth password.",
);
}
return {
url,
token: explicitAuth.token ?? envToken,
password,
allowInsecureLocalOperatorUi,
};
const resolved = await resolveGatewayInteractiveSurfaceAuth({
config,
env,
explicitAuth,
surface: "local",
});
if (resolved.failureReason) {
throwGatewayAuthResolutionError(resolved.failureReason);
}
const resolveToken = async () => {
const localToken = explicitAuth.token
? { value: explicitAuth.token }
: await resolveConfiguredSecretInputString({
value: config.gateway?.auth?.token,
path: "gateway.auth.token",
env,
config,
});
const token = explicitAuth.token ?? localToken.value ?? envToken;
if (!token) {
throwGatewayAuthResolutionError(
localToken.unresolvedRefReason ?? "Missing gateway auth token.",
);
}
return token;
};
if (gatewayAuthMode === "token") {
const token = await resolveToken();
return {
url,
token,
password: explicitAuth.password ?? envPassword,
allowInsecureLocalOperatorUi,
};
}
const passwordCandidate = explicitAuth.password ?? envPassword;
const shouldUsePassword =
Boolean(passwordCandidate) || (hasConfiguredPassword && !hasConfiguredToken);
if (shouldUsePassword) {
const localPassword = passwordCandidate
? { value: passwordCandidate }
: await resolveConfiguredSecretInputString({
value: config.gateway?.auth?.password,
path: "gateway.auth.password",
env,
config,
});
const password = explicitAuth.password ?? localPassword.value ?? envPassword;
if (!password) {
throwGatewayAuthResolutionError(
localPassword.unresolvedRefReason ?? "Missing gateway auth password.",
);
}
return {
url,
token: explicitAuth.token ?? envToken,
password,
allowInsecureLocalOperatorUi,
};
}
const token = await resolveToken();
return {
url,
token,
password: explicitAuth.password ?? envPassword,
token: resolved.token,
password: resolved.password,
allowInsecureLocalOperatorUi,
};
}