Files
openclaw/src/cli/command-secret-gateway.ts

612 lines
20 KiB
TypeScript

import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { callGateway } from "../gateway/call.js";
import { validateSecretsResolveResult } from "../gateway/protocol/index.js";
import {
analyzeCommandSecretAssignmentsFromSnapshot,
type UnresolvedCommandSecretAssignment,
} from "../secrets/command-config.js";
import { getPath, setPathExistingStrict } from "../secrets/path-utils.js";
import { resolveSecretRefValue } from "../secrets/resolve.js";
import { collectConfigAssignments } from "../secrets/runtime-config-collectors.js";
import { createResolverContext } from "../secrets/runtime-shared.js";
import { resolveRuntimeWebTools } from "../secrets/runtime-web-tools.js";
import { assertExpectedResolvedSecretValue } from "../secrets/secret-value.js";
import { describeUnknownError } from "../secrets/shared.js";
import {
discoverConfigSecretTargetsByIds,
type DiscoveredConfigSecretTarget,
} from "../secrets/target-registry.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
type ResolveCommandSecretsResult = {
resolvedConfig: OpenClawConfig;
diagnostics: string[];
targetStatesByPath: Record<string, CommandSecretTargetState>;
hadUnresolvedTargets: boolean;
};
export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret
export type CommandSecretTargetState =
| "resolved_gateway"
| "resolved_local"
| "inactive_surface"
| "unresolved";
type GatewaySecretsResolveResult = {
ok?: boolean;
assignments?: Array<{
path?: string;
pathSegments: string[];
value: unknown;
}>;
diagnostics?: string[];
inactiveRefPaths?: string[];
};
const WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES = [
"tools.web.search",
"tools.web.fetch.firecrawl",
] as const;
const WEB_RUNTIME_SECRET_PATH_PREFIXES = [
"tools.web.search.",
"tools.web.fetch.firecrawl.",
] as const;
function dedupeDiagnostics(entries: readonly string[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
for (const entry of entries) {
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
ordered.push(trimmed);
}
return ordered;
}
function targetsRuntimeWebPath(path: string): boolean {
return WEB_RUNTIME_SECRET_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
}
function targetsRuntimeWebResolution(params: {
targetIds: ReadonlySet<string>;
allowedPaths?: ReadonlySet<string>;
}): boolean {
if (params.allowedPaths) {
for (const path of params.allowedPaths) {
if (targetsRuntimeWebPath(path)) {
return true;
}
}
return false;
}
for (const targetId of params.targetIds) {
if (WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES.some((prefix) => targetId.startsWith(prefix))) {
return true;
}
}
return false;
}
function collectConfiguredTargetRefPaths(params: {
config: OpenClawConfig;
targetIds: Set<string>;
}): Set<string> {
const defaults = params.config.secrets?.defaults;
const configuredTargetRefPaths = new Set<string>();
for (const target of discoverConfigSecretTargetsByIds(params.config, params.targetIds)) {
const { ref } = resolveSecretInputRef({
value: target.value,
refValue: target.refValue,
defaults,
});
if (ref) {
configuredTargetRefPaths.add(target.path);
}
}
return configuredTargetRefPaths;
}
function classifyConfiguredTargetRefs(params: {
config: OpenClawConfig;
configuredTargetRefPaths: Set<string>;
}): {
hasActiveConfiguredRef: boolean;
hasUnknownConfiguredRef: boolean;
diagnostics: string[];
} {
if (params.configuredTargetRefPaths.size === 0) {
return {
hasActiveConfiguredRef: false,
hasUnknownConfiguredRef: false,
diagnostics: [],
};
}
const context = createResolverContext({
sourceConfig: params.config,
env: process.env,
});
collectConfigAssignments({
config: structuredClone(params.config),
context,
});
const activePaths = new Set(context.assignments.map((assignment) => assignment.path));
const inactiveWarningsByPath = new Map<string, string>();
for (const warning of context.warnings) {
if (warning.code !== "SECRETS_REF_IGNORED_INACTIVE_SURFACE") {
continue;
}
inactiveWarningsByPath.set(warning.path, warning.message);
}
const diagnostics = new Set<string>();
let hasActiveConfiguredRef = false;
let hasUnknownConfiguredRef = false;
for (const path of params.configuredTargetRefPaths) {
if (activePaths.has(path)) {
hasActiveConfiguredRef = true;
continue;
}
const inactiveWarning = inactiveWarningsByPath.get(path);
if (inactiveWarning) {
diagnostics.add(inactiveWarning);
continue;
}
hasUnknownConfiguredRef = true;
}
return {
hasActiveConfiguredRef,
hasUnknownConfiguredRef,
diagnostics: [...diagnostics],
};
}
function parseGatewaySecretsResolveResult(payload: unknown): {
assignments: Array<{ path?: string; pathSegments: string[]; value: unknown }>;
diagnostics: string[];
inactiveRefPaths: string[];
} {
if (!validateSecretsResolveResult(payload)) {
throw new Error("gateway returned invalid secrets.resolve payload.");
}
const parsed = payload as GatewaySecretsResolveResult;
return {
assignments: parsed.assignments ?? [],
diagnostics: (parsed.diagnostics ?? []).filter((entry) => entry.trim().length > 0),
inactiveRefPaths: (parsed.inactiveRefPaths ?? []).filter((entry) => entry.trim().length > 0),
};
}
function collectInactiveSurfacePathsFromDiagnostics(diagnostics: string[]): Set<string> {
const paths = new Set<string>();
for (const entry of diagnostics) {
const marker = ": secret ref is configured on an inactive surface;";
const markerIndex = entry.indexOf(marker);
if (markerIndex <= 0) {
continue;
}
const path = entry.slice(0, markerIndex).trim();
if (path.length > 0) {
paths.add(path);
}
}
return paths;
}
function isUnsupportedSecretsResolveError(err: unknown): boolean {
const message = describeUnknownError(err).toLowerCase();
if (!message.includes("secrets.resolve")) {
return false;
}
return (
message.includes("does not support required method") ||
message.includes("unknown method") ||
message.includes("method not found") ||
message.includes("invalid request")
);
}
async function resolveCommandSecretRefsLocally(params: {
config: OpenClawConfig;
commandName: string;
targetIds: Set<string>;
preflightDiagnostics: string[];
mode: CommandSecretResolutionMode;
allowedPaths?: ReadonlySet<string>;
}): Promise<ResolveCommandSecretsResult> {
const sourceConfig = params.config;
const resolvedConfig = structuredClone(params.config);
const context = createResolverContext({
sourceConfig,
env: process.env,
});
const localResolutionDiagnostics: string[] = [];
collectConfigAssignments({
config: structuredClone(params.config),
context,
});
if (
targetsRuntimeWebResolution({ targetIds: params.targetIds, allowedPaths: params.allowedPaths })
) {
try {
await resolveRuntimeWebTools({
sourceConfig,
resolvedConfig,
context,
});
} catch (error) {
if (params.mode === "strict") {
throw error;
}
localResolutionDiagnostics.push(
`${params.commandName}: failed to resolve web tool secrets locally (${describeUnknownError(error)}).`,
);
}
}
const inactiveRefPaths = new Set(
context.warnings
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
.filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path))
.map((warning) => warning.path),
);
const inactiveWarningDiagnostics = context.warnings
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
.filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path))
.map((warning) => warning.message);
const activePaths = new Set(context.assignments.map((assignment) => assignment.path));
for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) {
if (params.allowedPaths && !params.allowedPaths.has(target.path)) {
continue;
}
await resolveTargetSecretLocally({
target,
sourceConfig,
resolvedConfig,
env: context.env,
cache: context.cache,
activePaths,
inactiveRefPaths,
mode: params.mode,
commandName: params.commandName,
localResolutionDiagnostics,
});
}
const analyzed = analyzeCommandSecretAssignmentsFromSnapshot({
sourceConfig,
resolvedConfig,
targetIds: params.targetIds,
inactiveRefPaths,
...(params.allowedPaths ? { allowedPaths: params.allowedPaths } : {}),
});
const targetStatesByPath = buildTargetStatesByPath({
analyzed,
resolvedState: "resolved_local",
});
if (params.mode !== "strict" && analyzed.unresolved.length > 0) {
scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved);
} else if (analyzed.unresolved.length > 0) {
throw new Error(
`${params.commandName}: ${analyzed.unresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`,
);
}
return {
resolvedConfig,
diagnostics: dedupeDiagnostics([
...params.preflightDiagnostics,
...inactiveWarningDiagnostics,
...filterInactiveSurfaceDiagnostics({
diagnostics: analyzed.diagnostics,
inactiveRefPaths,
}),
...localResolutionDiagnostics,
...buildUnresolvedDiagnostics(params.commandName, analyzed.unresolved, params.mode),
]),
targetStatesByPath,
hadUnresolvedTargets: analyzed.unresolved.length > 0,
};
}
function buildTargetStatesByPath(params: {
analyzed: ReturnType<typeof analyzeCommandSecretAssignmentsFromSnapshot>;
resolvedState: Extract<CommandSecretTargetState, "resolved_gateway" | "resolved_local">;
}): Record<string, CommandSecretTargetState> {
const states: Record<string, CommandSecretTargetState> = {};
for (const assignment of params.analyzed.assignments) {
states[assignment.path] = params.resolvedState;
}
for (const entry of params.analyzed.inactive) {
states[entry.path] = "inactive_surface";
}
for (const entry of params.analyzed.unresolved) {
states[entry.path] = "unresolved";
}
return states;
}
function buildUnresolvedDiagnostics(
commandName: string,
unresolved: UnresolvedCommandSecretAssignment[],
mode: CommandSecretResolutionMode,
): string[] {
if (mode === "strict") {
return [];
}
return unresolved.map(
(entry) =>
`${commandName}: ${entry.path} is unavailable in this command path; continuing with degraded read-only config.`,
);
}
function scrubUnresolvedAssignments(
config: OpenClawConfig,
unresolved: UnresolvedCommandSecretAssignment[],
): void {
for (const entry of unresolved) {
setPathExistingStrict(config, entry.pathSegments, undefined);
}
}
function filterInactiveSurfaceDiagnostics(params: {
diagnostics: readonly string[];
inactiveRefPaths: ReadonlySet<string>;
}): string[] {
return params.diagnostics.filter((entry) => {
const marker = ": secret ref is configured on an inactive surface;";
const markerIndex = entry.indexOf(marker);
if (markerIndex <= 0) {
return true;
}
const path = entry.slice(0, markerIndex).trim();
return !params.inactiveRefPaths.has(path);
});
}
async function resolveTargetSecretLocally(params: {
target: DiscoveredConfigSecretTarget;
sourceConfig: OpenClawConfig;
resolvedConfig: OpenClawConfig;
env: NodeJS.ProcessEnv;
cache: ReturnType<typeof createResolverContext>["cache"];
activePaths: ReadonlySet<string>;
inactiveRefPaths: ReadonlySet<string>;
mode: CommandSecretResolutionMode;
commandName: string;
localResolutionDiagnostics: string[];
}): Promise<void> {
const defaults = params.sourceConfig.secrets?.defaults;
const { ref } = resolveSecretInputRef({
value: params.target.value,
refValue: params.target.refValue,
defaults,
});
if (
!ref ||
params.inactiveRefPaths.has(params.target.path) ||
!params.activePaths.has(params.target.path)
) {
return;
}
try {
const resolved = await resolveSecretRefValue(ref, {
config: params.sourceConfig,
env: params.env,
cache: params.cache,
});
assertExpectedResolvedSecretValue({
value: resolved,
expected: params.target.entry.expectedResolvedValue,
errorMessage:
params.target.entry.expectedResolvedValue === "string"
? `${params.target.path} resolved to a non-string or empty value.`
: `${params.target.path} resolved to an unsupported value type.`,
});
setPathExistingStrict(params.resolvedConfig, params.target.pathSegments, resolved);
} catch (error) {
if (params.mode !== "strict") {
params.localResolutionDiagnostics.push(
`${params.commandName}: failed to resolve ${params.target.path} locally (${describeUnknownError(error)}).`,
);
}
}
}
export async function resolveCommandSecretRefsViaGateway(params: {
config: OpenClawConfig;
commandName: string;
targetIds: Set<string>;
mode?: CommandSecretResolutionMode;
}): Promise<ResolveCommandSecretsResult> {
const mode = params.mode ?? "strict";
const configuredTargetRefPaths = collectConfiguredTargetRefPaths({
config: params.config,
targetIds: params.targetIds,
});
if (configuredTargetRefPaths.size === 0) {
return {
resolvedConfig: params.config,
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
};
}
const preflight = classifyConfiguredTargetRefs({
config: params.config,
configuredTargetRefPaths,
});
if (!preflight.hasActiveConfiguredRef && !preflight.hasUnknownConfiguredRef) {
return {
resolvedConfig: params.config,
diagnostics: preflight.diagnostics,
targetStatesByPath: {},
hadUnresolvedTargets: false,
};
}
let payload: GatewaySecretsResolveResult;
try {
payload = await callGateway<GatewaySecretsResolveResult>({
config: params.config,
method: "secrets.resolve",
requiredMethods: ["secrets.resolve"],
params: {
commandName: params.commandName,
targetIds: [...params.targetIds],
},
timeoutMs: 30_000,
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
});
} catch (err) {
try {
const fallback = await resolveCommandSecretRefsLocally({
config: params.config,
commandName: params.commandName,
targetIds: params.targetIds,
preflightDiagnostics: preflight.diagnostics,
mode,
});
const recoveredLocally = Object.values(fallback.targetStatesByPath).some(
(state) => state === "resolved_local",
);
const fallbackMessage =
recoveredLocally && !fallback.hadUnresolvedTargets
? "resolved command secrets locally."
: "attempted local command-secret resolution.";
return {
resolvedConfig: fallback.resolvedConfig,
diagnostics: dedupeDiagnostics([
...fallback.diagnostics,
`${params.commandName}: gateway secrets.resolve unavailable (${describeUnknownError(err)}); ${fallbackMessage}`,
]),
targetStatesByPath: fallback.targetStatesByPath,
hadUnresolvedTargets: fallback.hadUnresolvedTargets,
};
} catch {
// Fall through to original gateway-specific error reporting.
}
if (isUnsupportedSecretsResolveError(err)) {
throw new Error(
`${params.commandName}: active gateway does not support secrets.resolve (${describeUnknownError(err)}). Update the gateway or run without SecretRefs.`,
{ cause: err },
);
}
throw new Error(
`${params.commandName}: failed to resolve secrets from the active gateway snapshot (${describeUnknownError(err)}). Start the gateway and retry.`,
{ cause: err },
);
}
const parsed = parseGatewaySecretsResolveResult(payload);
const resolvedConfig = structuredClone(params.config);
for (const assignment of parsed.assignments) {
const pathSegments = assignment.pathSegments.filter((segment) => segment.length > 0);
if (pathSegments.length === 0) {
continue;
}
try {
setPathExistingStrict(resolvedConfig, pathSegments, assignment.value);
} catch (err) {
const path = pathSegments.join(".");
throw new Error(
`${params.commandName}: failed to apply resolved secret assignment at ${path} (${describeUnknownError(err)}).`,
{ cause: err },
);
}
}
const inactiveRefPaths =
parsed.inactiveRefPaths.length > 0
? new Set(parsed.inactiveRefPaths)
: collectInactiveSurfacePathsFromDiagnostics(parsed.diagnostics);
const analyzed = analyzeCommandSecretAssignmentsFromSnapshot({
sourceConfig: params.config,
resolvedConfig,
targetIds: params.targetIds,
inactiveRefPaths,
});
let diagnostics = dedupeDiagnostics(parsed.diagnostics);
const targetStatesByPath = buildTargetStatesByPath({
analyzed,
resolvedState: "resolved_gateway",
});
if (analyzed.unresolved.length > 0) {
try {
const localFallback = await resolveCommandSecretRefsLocally({
config: params.config,
commandName: params.commandName,
targetIds: params.targetIds,
preflightDiagnostics: [],
mode,
allowedPaths: new Set(analyzed.unresolved.map((entry) => entry.path)),
});
for (const unresolved of analyzed.unresolved) {
if (localFallback.targetStatesByPath[unresolved.path] !== "resolved_local") {
continue;
}
setPathExistingStrict(
resolvedConfig,
unresolved.pathSegments,
getPath(localFallback.resolvedConfig, unresolved.pathSegments),
);
targetStatesByPath[unresolved.path] = "resolved_local";
}
const recoveredPaths = new Set(
Object.entries(localFallback.targetStatesByPath)
.filter(([, state]) => state === "resolved_local")
.map(([path]) => path),
);
const stillUnresolved = analyzed.unresolved.filter(
(entry) => !recoveredPaths.has(entry.path),
);
if (stillUnresolved.length > 0) {
if (mode === "strict") {
throw new Error(
`${params.commandName}: ${stillUnresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`,
);
}
scrubUnresolvedAssignments(resolvedConfig, stillUnresolved);
diagnostics = dedupeDiagnostics([
...diagnostics,
...localFallback.diagnostics,
...buildUnresolvedDiagnostics(params.commandName, stillUnresolved, mode),
]);
for (const unresolved of stillUnresolved) {
targetStatesByPath[unresolved.path] = "unresolved";
}
} else if (recoveredPaths.size > 0) {
diagnostics = dedupeDiagnostics([
...diagnostics,
`${params.commandName}: resolved ${recoveredPaths.size} secret ${
recoveredPaths.size === 1 ? "path" : "paths"
} locally after the gateway snapshot was incomplete.`,
]);
}
} catch (error) {
if (mode === "strict") {
throw error;
}
scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved);
diagnostics = dedupeDiagnostics([
...diagnostics,
`${params.commandName}: local fallback after incomplete gateway snapshot failed (${describeUnknownError(error)}).`,
...buildUnresolvedDiagnostics(params.commandName, analyzed.unresolved, mode),
]);
}
}
return {
resolvedConfig,
diagnostics,
targetStatesByPath,
hadUnresolvedTargets: Object.values(targetStatesByPath).includes("unresolved"),
};
}