diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index cb50eb66374..3eec8052f15 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -10,16 +10,24 @@ type GatewayClientAuth = { token?: string; password?: string; }; -type ResolveGatewayConnectionAuth = (params: unknown) => Promise; +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(async (_params) => ({ - token: undefined, - password: undefined, + resolveGatewayClientBootstrap: vi.fn(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", }), ); diff --git a/src/acp/server.ts b/src/acp/server.ts index c19f48b3631..27d8bfa6427 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -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 { 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 ({})); +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(); }); diff --git a/src/cli/daemon-cli/gateway-token-drift.ts b/src/cli/daemon-cli/gateway-token-drift.ts index 7e5aa4f377a..ea724828026 100644 --- a/src/cli/daemon-cli/gateway-token-drift.ts +++ b/src/cli/daemon-cli/gateway-token-drift.ts @@ -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"); } diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index eedad89cd17..86985e39080 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -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, ): Promise { @@ -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 diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index 6957c200cfc..6bd5ef03a40 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -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.", ); diff --git a/src/commands/doctor-gateway-auth-token.ts b/src/commands/doctor-gateway-auth-token.ts index 8bbac6722fc..2767d39100c 100644 --- a/src/commands/doctor-gateway-auth-token.ts +++ b/src/commands/doctor-gateway-auth-token.ts @@ -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 {}; diff --git a/src/commands/gateway-install-token.ts b/src/commands/gateway-install-token.ts index 49630a5e967..181ae57cf34 100644 --- a/src/commands/gateway-install-token.ts +++ b/src/commands/gateway-install-token.ts @@ -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["ref"]>; - config: OpenClawConfig; - env: NodeJS.ProcessEnv; -}): Promise { - 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 { 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 diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index e2597bbc89f..62955a30736 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -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 => { - 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 => { - 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 = (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 }; diff --git a/src/commands/status.gateway-probe.ts b/src/commands/status.gateway-probe.ts index e401d8c28ba..8371a85a30c 100644 --- a/src/commands/status.gateway-probe.ts +++ b/src/commands/status.gateway-probe.ts @@ -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, }); } diff --git a/src/gateway/auth-config-utils.ts b/src/gateway/auth-config-utils.ts index 7f1ca9fd0ed..1c66a2e42e5 100644 --- a/src/gateway/auth-config-utils.ts +++ b/src/gateway/auth-config-utils.ts @@ -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, +): boolean { + return shouldResolveGatewayAuthSecretRef({ + mode: params.mode, + path: "gateway.auth.token", + hasPasswordCandidate: params.hasPasswordCandidate, + hasTokenCandidate: params.hasTokenCandidate, + }); +} + +export function shouldResolveGatewayPasswordSecretRef( + params: Omit, +): 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 { + 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 { + return resolveGatewayAuthSecretRefValue({ + cfg: params.cfg, + env: params.env, + path: "gateway.auth.token", + shouldResolve: shouldResolveGatewayTokenSecretRef(params), + }); +} + +export async function resolveGatewayPasswordSecretRefValue( + params: GatewayAuthSecretRefResolutionParams, +): Promise { + 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 { + 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 { - 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 { + 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); } diff --git a/src/gateway/auth-surface-resolution.ts b/src/gateway/auth-surface-resolution.ts new file mode 100644 index 00000000000..9d6e900566f --- /dev/null +++ b/src/gateway/auth-surface-resolution.ts @@ -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 { + 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(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, + }; +} diff --git a/src/gateway/auth-token-resolution.ts b/src/gateway/auth-token-resolution.ts new file mode 100644 index 00000000000..21c65200ccf --- /dev/null +++ b/src/gateway/auth-token-resolution.ts @@ -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, + }; +} diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 582810c24d0..c7490840a48 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -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 { - 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; diff --git a/src/gateway/client-bootstrap.test.ts b/src/gateway/client-bootstrap.test.ts new file mode 100644 index 00000000000..99c9b9c6e5c --- /dev/null +++ b/src/gateway/client-bootstrap.test.ts @@ -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, + }), + ); + }); +}); diff --git a/src/gateway/client-bootstrap.ts b/src/gateway/client-bootstrap.ts new file mode 100644 index 00000000000..fe5715d6357 --- /dev/null +++ b/src/gateway/client-bootstrap.ts @@ -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, + }; +} diff --git a/src/gateway/operator-approvals-client.ts b/src/gateway/operator-approvals-client.ts index 82ea7c80413..cb3b063bb43 100644 --- a/src/gateway/operator-approvals-client.ts +++ b/src/gateway/operator-approvals-client.ts @@ -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 { - 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, diff --git a/src/gateway/probe-auth.test.ts b/src/gateway/probe-auth.test.ts index b95eebf58db..217fb4cc092 100644 --- a/src/gateway/probe-auth.test.ts +++ b/src/gateway/probe-auth.test.ts @@ -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({ diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index 423f0a77c3c..5f014080b63 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -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), }; } } diff --git a/src/gateway/secret-input-paths.ts b/src/gateway/secret-input-paths.ts new file mode 100644 index 00000000000..bd835e72dbf --- /dev/null +++ b/src/gateway/secret-input-paths.ts @@ -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"; +} diff --git a/src/gateway/server-methods/approval-shared.ts b/src/gateway/server-methods/approval-shared.ts new file mode 100644 index 00000000000..e710ab74745 --- /dev/null +++ b/src/gateway/server-methods/approval-shared.ts @@ -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 = { + id: string; + request: TPayload; + createdAtMs: number; + expiresAtMs: number; +}; + +function isPromiseLike(value: T | Promise): value is Promise { + 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(params: { + manager: ExecApprovalManager; + inputId: string; + exposeAmbiguousPrefixError?: boolean; +}): + | { + ok: true; + approvalId: string; + snapshot: ExecApprovalRecord; + } + | { + 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(params: { + manager: ExecApprovalManager; + inputId: unknown; + respond: RespondFn; +}): Promise { + 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; + record: ExecApprovalRecord; + decisionPromise: Promise; + respond: RespondFn; + context: GatewayRequestContext; + clientConnId?: string; + requestEventName: string; + requestEvent: RequestedApprovalEvent; + twoPhase: boolean; + deliverRequest: () => boolean | Promise; + afterDecision?: ( + decision: ExecApprovalDecision | null, + requestEvent: RequestedApprovalEvent, + ) => Promise | void; + afterDecisionErrorLabel?: string; +}): Promise { + 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(params: { + manager: ExecApprovalManager; + inputId: string; + decision: ExecApprovalDecision; + respond: RespondFn; + context: GatewayRequestContext; + client: GatewayClient | null; + exposeAmbiguousPrefixError?: boolean; + validateDecision?: (snapshot: ExecApprovalRecord) => + | { + message: string; + details?: Record; + } + | null + | undefined; + resolvedEventName: string; + buildResolvedEvent: (params: { + approvalId: string; + decision: ExecApprovalDecision; + resolvedBy: string | null; + snapshot: ExecApprovalRecord; + nowMs: number; + }) => TResolvedEvent; + forwardResolved?: (event: TResolvedEvent) => Promise | void; + forwardResolvedErrorLabel?: string; + extraResolvedHandlers?: Array<{ + run: (event: TResolvedEvent) => Promise | void; + errorLabel: string; + }>; +}): Promise { + 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; 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); +} diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index 104375399fd..ea0943d1fe7 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -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; }; -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> = []; + 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); }, }; } diff --git a/src/gateway/server-methods/plugin-approval.ts b/src/gateway/server-methods/plugin-approval.ts index d3437e4f437..28e8e3c4bd4 100644 --- a/src/gateway/server-methods/plugin-approval.ts +++ b/src/gateway/server-methods/plugin-approval.ts @@ -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, 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", + }); }, }; } diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 19b4d2c9319..4e7f1528fd0 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -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", diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 00167b9da24..495dc9bb714 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -92,6 +92,88 @@ async function resolveRequestedChannel(params: { return { cfg, channel }; } +function resolveGatewayOutboundTarget(params: { + channel: string; + to: string; + cfg: ReturnType; + accountId?: string; +}): + | { + ok: true; + to: string; + } + | { + ok: false; + error: ReturnType; + } { + 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; +}): Record { + const payload: Record = { + 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; +}) { + params.context.dedupe.set(params.dedupeKey, { + ts: Date.now(), + ok: true, + payload: params.payload, + }); +} + +function cacheGatewayDedupeFailure(params: { + context: GatewayRequestContext; + dedupeKey: string; + error: ReturnType; +}) { + 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 => { 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 = { - 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 = { - 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, { diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index cbf2c19444c..eeabe2e3fe6 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -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 { - 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 { - 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 diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index fecee19a0b1..33b1222ec86 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -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; handleResolved: (resolved: ExecApprovalResolved) => Promise; @@ -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(params: { + target: ForwardTarget; + renderParams: TParams; + resolveRenderer: ( + adapter: ReturnType | 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 = { +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, + ) => ReplyPayload; + buildResolvedPayload: ( + params: ApprovalResolvedRenderContext, + ) => ReplyPayload; +}): ApprovalStrategy { + 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({ 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 = { +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 { - 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 { - 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 { - 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 { 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 }; diff --git a/src/security/audit.ts b/src/security/audit.ts index a32c7048356..ec16045b01c 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -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) => ({ diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index d3ebd5407e4..963f19246b7 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -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, }; }