Compare commits

...

5 Commits

Author SHA1 Message Date
joshavant
209287d5e0 Secrets runtime: include sourceConfig in prepared snapshot type 2026-02-21 17:17:50 -08:00
joshavant
bb052deec6 Secrets: preserve runtime snapshot source refs on write 2026-02-21 17:09:04 -08:00
joshavant
06e51505bc Gateway: avoid double secrets activation at startup 2026-02-21 16:04:15 -08:00
joshavant
2b7206470a Gateway: add eager secrets runtime snapshot activation 2026-02-21 16:04:15 -08:00
joshavant
7103ba3758 Config: add secret ref schema and redaction foundations 2026-02-21 16:03:36 -08:00
28 changed files with 1287 additions and 26 deletions

View File

@@ -17,7 +17,10 @@ export {
suggestOAuthProfileIdForLegacyDefault,
} from "./auth-profiles/repair.js";
export {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
loadAuthProfileStoreForRuntime,
replaceRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStore,
saveAuthProfileStore,
} from "./auth-profiles/store.js";

View File

@@ -18,6 +18,65 @@ function _syncAuthProfileStore(target: AuthProfileStore, source: AuthProfileStor
target.usageStats = source.usageStats;
}
const runtimeAuthStoreSnapshots = new Map<string, AuthProfileStore>();
function resolveRuntimeStoreKey(agentDir?: string): string {
return resolveAuthStorePath(agentDir);
}
function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
return structuredClone(store);
}
function resolveRuntimeAuthProfileStore(agentDir?: string): AuthProfileStore | null {
if (runtimeAuthStoreSnapshots.size === 0) {
return null;
}
const mainKey = resolveRuntimeStoreKey(undefined);
const requestedKey = resolveRuntimeStoreKey(agentDir);
const mainStore = runtimeAuthStoreSnapshots.get(mainKey);
const requestedStore = runtimeAuthStoreSnapshots.get(requestedKey);
if (!agentDir || requestedKey === mainKey) {
if (!mainStore) {
return null;
}
return cloneAuthProfileStore(mainStore);
}
if (mainStore && requestedStore) {
return mergeAuthProfileStores(
cloneAuthProfileStore(mainStore),
cloneAuthProfileStore(requestedStore),
);
}
if (requestedStore) {
return cloneAuthProfileStore(requestedStore);
}
if (mainStore) {
return cloneAuthProfileStore(mainStore);
}
return null;
}
export function replaceRuntimeAuthProfileStoreSnapshots(
entries: Array<{ agentDir?: string; store: AuthProfileStore }>,
): void {
runtimeAuthStoreSnapshots.clear();
for (const entry of entries) {
runtimeAuthStoreSnapshots.set(
resolveRuntimeStoreKey(entry.agentDir),
cloneAuthProfileStore(entry.store),
);
}
}
export function clearRuntimeAuthProfileStoreSnapshots(): void {
runtimeAuthStoreSnapshots.clear();
}
export async function updateAuthProfileStoreWithLock(params: {
agentDir?: string;
updater: (store: AuthProfileStore) => boolean;
@@ -316,10 +375,30 @@ function loadAuthProfileStoreForAgent(
return store;
}
export function loadAuthProfileStoreForRuntime(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const store = loadAuthProfileStoreForAgent(agentDir, options);
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return store;
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return mergeAuthProfileStores(mainStore, store);
}
export function ensureAuthProfileStore(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir);
if (runtimeStore) {
return runtimeStore;
}
const store = loadAuthProfileStoreForAgent(agentDir, options);
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();

View File

@@ -1,10 +1,12 @@
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import type { OpenClawConfig } from "../../config/config.js";
import type { SecretRef } from "../../config/types.secrets.js";
export type ApiKeyCredential = {
type: "api_key";
provider: string;
key?: string;
keyRef?: SecretRef;
email?: string;
/** Optional provider-specific metadata (e.g., account IDs, gateway IDs). */
metadata?: Record<string, string>;
@@ -18,6 +20,7 @@ export type TokenCredential = {
type: "token";
provider: string;
token: string;
tokenRef?: SecretRef;
/** Optional expiry timestamp (ms since epoch). */
expires?: number;
email?: string;

View File

@@ -5,6 +5,7 @@ import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
import {
@@ -387,16 +388,17 @@ export function normalizeProviders(params: {
for (const [key, provider] of Object.entries(providers)) {
const normalizedKey = key.trim();
let normalizedProvider = provider;
const configuredApiKey = normalizedProvider.apiKey;
// Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR".
if (
normalizedProvider.apiKey &&
normalizeApiKeyConfig(normalizedProvider.apiKey) !== normalizedProvider.apiKey
typeof configuredApiKey === "string" &&
normalizeApiKeyConfig(configuredApiKey) !== configuredApiKey
) {
mutated = true;
normalizedProvider = {
...normalizedProvider,
apiKey: normalizeApiKeyConfig(normalizedProvider.apiKey),
apiKey: normalizeApiKeyConfig(configuredApiKey),
};
}
@@ -404,7 +406,9 @@ export function normalizeProviders(params: {
// Fill it from the environment or auth profiles when possible.
const hasModels =
Array.isArray(normalizedProvider.models) && normalizedProvider.models.length > 0;
if (hasModels && !normalizedProvider.apiKey?.trim()) {
const normalizedApiKey = normalizeOptionalSecretInput(normalizedProvider.apiKey);
const hasConfiguredApiKey = Boolean(normalizedApiKey || normalizedProvider.apiKey);
if (hasModels && !hasConfiguredApiKey) {
const authMode =
normalizedProvider.auth ?? (normalizedKey === "amazon-bedrock" ? "aws-sdk" : undefined);
if (authMode === "aws-sdk") {

View File

@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.models.js";
import type { RuntimeEnv } from "../runtime.js";
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyPrimaryModel } from "./model-picker.js";
import { normalizeAlias } from "./models/shared.js";
@@ -521,7 +522,7 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom
const mergedModels = hasModel ? existingModels : [...existingModels, nextModel];
const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {};
const normalizedApiKey =
params.apiKey?.trim() || (existingApiKey ? existingApiKey.trim() : undefined);
normalizeOptionalSecretInput(params.apiKey) ?? normalizeOptionalSecretInput(existingApiKey);
let config: OpenClawConfig = {
...params.config,

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { validateConfigObjectRaw } from "./validation.js";
describe("config secret refs schema", () => {
it("accepts top-level secrets sources and model apiKey refs", () => {
const result = validateConfigObjectRaw({
secrets: {
sources: {
env: { type: "env" },
file: { type: "sops", path: "~/.openclaw/secrets.enc.json", timeoutMs: 10_000 },
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "OPENAI_API_KEY" },
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
});
expect(result.ok).toBe(true);
});
it("accepts googlechat serviceAccount refs", () => {
const result = validateConfigObjectRaw({
channels: {
googlechat: {
serviceAccountRef: { source: "file", id: "/channels/googlechat/serviceAccount" },
},
},
});
expect(result.ok).toBe(true);
});
it("rejects invalid secret ref id", () => {
const result = validateConfigObjectRaw({
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "bad id with spaces" },
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(
result.issues.some((issue) => issue.path.includes("models.providers.openai.apiKey")),
).toBe(true);
}
});
});

View File

@@ -1,11 +1,14 @@
export {
clearConfigCache,
clearRuntimeConfigSnapshot,
createConfigIO,
getRuntimeConfigSnapshot,
loadConfig,
parseConfigJson5,
readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
resolveConfigSnapshotHash,
setRuntimeConfigSnapshot,
writeConfigFile,
} from "./io.js";
export { migrateLegacyConfig } from "./legacy-migrate.js";

View File

@@ -0,0 +1,63 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "./home-env.test-harness.js";
import {
clearConfigCache,
clearRuntimeConfigSnapshot,
loadConfig,
setRuntimeConfigSnapshot,
writeConfigFile,
} from "./io.js";
import type { OpenClawConfig } from "./types.js";
describe("runtime config snapshot writes", () => {
it("preserves source secret refs when writeConfigFile receives runtime-resolved config", async () => {
await withTempHome("openclaw-config-runtime-write-", async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const sourceConfig: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "OPENAI_API_KEY" },
models: [],
},
},
},
};
const runtimeConfig: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-runtime-resolved",
models: [],
},
},
},
};
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
try {
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-runtime-resolved");
await writeConfigFile(loadConfig());
const persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
models?: { providers?: { openai?: { apiKey?: unknown } } };
};
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
source: "env",
id: "OPENAI_API_KEY",
});
} finally {
clearRuntimeConfigSnapshot();
clearConfigCache();
}
});
});
});

View File

@@ -1061,6 +1061,8 @@ let configCache: {
expiresAt: number;
config: OpenClawConfig;
} | null = null;
let runtimeConfigSnapshot: OpenClawConfig | null = null;
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
const raw = env.OPENCLAW_CONFIG_CACHE_MS?.trim();
@@ -1088,7 +1090,29 @@ export function clearConfigCache(): void {
configCache = null;
}
export function setRuntimeConfigSnapshot(
config: OpenClawConfig,
sourceConfig?: OpenClawConfig,
): void {
runtimeConfigSnapshot = config;
runtimeConfigSourceSnapshot = sourceConfig ?? null;
clearConfigCache();
}
export function clearRuntimeConfigSnapshot(): void {
runtimeConfigSnapshot = null;
runtimeConfigSourceSnapshot = null;
clearConfigCache();
}
export function getRuntimeConfigSnapshot(): OpenClawConfig | null {
return runtimeConfigSnapshot;
}
export function loadConfig(): OpenClawConfig {
if (runtimeConfigSnapshot) {
return runtimeConfigSnapshot;
}
const io = createConfigIO();
const configPath = io.configPath;
const now = Date.now();
@@ -1125,9 +1149,14 @@ export async function writeConfigFile(
options: ConfigWriteOptions = {},
): Promise<void> {
const io = createConfigIO();
let nextCfg = cfg;
if (runtimeConfigSnapshot && runtimeConfigSourceSnapshot) {
const runtimePatch = createMergePatch(runtimeConfigSnapshot, cfg);
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch));
}
const sameConfigPath =
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
await io.writeConfigFile(cfg, {
await io.writeConfigFile(nextCfg, {
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
});
}

View File

@@ -74,7 +74,6 @@ describe("redactConfigSnapshot", () => {
},
shortSecret: { token: "short" },
});
const result = redactConfigSnapshot(snapshot);
const cfg = result.config as typeof snapshot.config;
@@ -91,6 +90,45 @@ describe("redactConfigSnapshot", () => {
expect(cfg.shortSecret.token).toBe(REDACTED_SENTINEL);
});
it("redacts googlechat serviceAccount object payloads", () => {
const snapshot = makeSnapshot({
channels: {
googlechat: {
serviceAccount: {
type: "service_account",
client_email: "bot@example.iam.gserviceaccount.com",
private_key: "-----BEGIN PRIVATE KEY-----secret-----END PRIVATE KEY-----",
},
},
},
});
const result = redactConfigSnapshot(snapshot);
const channels = result.config.channels as Record<string, Record<string, unknown>>;
expect(channels.googlechat.serviceAccount).toBe(REDACTED_SENTINEL);
});
it("redacts object-valued apiKey refs in model providers", () => {
const snapshot = makeSnapshot({
models: {
providers: {
openai: {
apiKey: { source: "env", id: "OPENAI_API_KEY" },
baseUrl: "https://api.openai.com",
},
},
},
});
const result = redactConfigSnapshot(snapshot);
const models = result.config.models as Record<string, Record<string, Record<string, unknown>>>;
expect(models.providers.openai.apiKey).toEqual({
source: REDACTED_SENTINEL,
id: REDACTED_SENTINEL,
});
expect(models.providers.openai.baseUrl).toBe("https://api.openai.com");
});
it("preserves non-sensitive fields", () => {
const snapshot = makeSnapshot({
ui: { seamColor: "#0088cc" },

View File

@@ -17,6 +17,31 @@ function isEnvVarPlaceholder(value: string): boolean {
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
}
function isWholeObjectSensitivePath(path: string): boolean {
const lowered = path.toLowerCase();
return lowered.endsWith("serviceaccount") || lowered.endsWith("serviceaccountref");
}
function collectSensitiveStrings(value: unknown, values: string[]): void {
if (typeof value === "string") {
if (!isEnvVarPlaceholder(value)) {
values.push(value);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectSensitiveStrings(item, values);
}
return;
}
if (value && typeof value === "object") {
for (const item of Object.values(value as Record<string, unknown>)) {
collectSensitiveStrings(item, values);
}
}
}
function isExtensionPath(path: string): boolean {
return (
path === "plugins" ||
@@ -159,7 +184,19 @@ function redactObjectWithLookup(
result[key] = REDACTED_SENTINEL;
values.push(value);
} else if (typeof value === "object" && value !== null) {
result[key] = redactObjectWithLookup(value, lookup, candidate, values, hints);
if (hints[candidate]?.sensitive === true && !Array.isArray(value)) {
collectSensitiveStrings(value, values);
result[key] = REDACTED_SENTINEL;
} else {
result[key] = redactObjectWithLookup(value, lookup, candidate, values, hints);
}
} else if (
hints[candidate]?.sensitive === true &&
value !== undefined &&
value !== null
) {
// Keep primitives at explicitly-sensitive paths fully redacted.
result[key] = REDACTED_SENTINEL;
}
break;
}
@@ -228,6 +265,16 @@ function redactObjectGuessing(
) {
result[key] = REDACTED_SENTINEL;
values.push(value);
} else if (
!isExplicitlyNonSensitivePath(hints, [dotPath, wildcardPath]) &&
isSensitivePath(dotPath) &&
isWholeObjectSensitivePath(dotPath) &&
value &&
typeof value === "object" &&
!Array.isArray(value)
) {
collectSensitiveStrings(value, values);
result[key] = REDACTED_SENTINEL;
} else if (typeof value === "object" && value !== null) {
result[key] = redactObjectGuessing(value, dotPath, values, hints);
} else {

View File

@@ -109,6 +109,7 @@ describe("mapSensitivePaths", () => {
expect(hints["agents.defaults.memorySearch.remote.apiKey"]?.sensitive).toBe(true);
expect(hints["agents.list[].memorySearch.remote.apiKey"]?.sensitive).toBe(true);
expect(hints["channels.discord.accounts.*.token"]?.sensitive).toBe(true);
expect(hints["channels.googlechat.serviceAccount"]?.sensitive).toBe(true);
expect(hints["gateway.auth.token"]?.sensitive).toBe(true);
expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true);
});

View File

@@ -107,7 +107,13 @@ const NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES = SENSITIVE_KEY_WHITELIST_SUFF
suffix.toLowerCase(),
);
const SENSITIVE_PATTERNS = [/token$/i, /password/i, /secret/i, /api.?key/i];
const SENSITIVE_PATTERNS = [
/token$/i,
/password/i,
/secret/i,
/api.?key/i,
/serviceaccount(?:ref)?$/i,
];
function isWhitelistedSensitivePath(path: string): boolean {
const lowerPath = path.toLowerCase();

View File

@@ -5,6 +5,7 @@ import type {
ReplyToMode,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
import type { SecretRef } from "./types.secrets.js";
export type GoogleChatDmConfig = {
/** If false, ignore all incoming Google Chat DMs. Default: true. */
@@ -58,8 +59,10 @@ export type GoogleChatAccountConfig = {
defaultTo?: string;
/** Per-space configuration keyed by space id or name. */
groups?: Record<string, GoogleChatGroupConfig>;
/** Service account JSON (inline string or object). */
serviceAccount?: string | Record<string, unknown>;
/** Service account JSON (inline string, object, or secret reference). */
serviceAccount?: string | Record<string, unknown> | SecretRef;
/** Explicit secret reference for service account JSON. */
serviceAccountRef?: SecretRef;
/** Service account JSON file path. */
serviceAccountFile?: string;
/** Webhook audience type (app-url or project-number). */

View File

@@ -1,3 +1,5 @@
import type { SecretInput } from "./types.secrets.js";
export type ModelApi =
| "openai-completions"
| "openai-responses"
@@ -43,7 +45,7 @@ export type ModelDefinitionConfig = {
export type ModelProviderConfig = {
baseUrl: string;
apiKey?: string;
apiKey?: SecretInput;
auth?: ModelProviderAuthMode;
api?: ModelApi;
headers?: Record<string, string>;

View File

@@ -22,6 +22,7 @@ import type {
import type { ModelsConfig } from "./types.models.js";
import type { NodeHostConfig } from "./types.node-host.js";
import type { PluginsConfig } from "./types.plugins.js";
import type { SecretsConfig } from "./types.secrets.js";
import type { SkillsConfig } from "./types.skills.js";
import type { ToolsConfig } from "./types.tools.js";
@@ -75,6 +76,7 @@ export type OpenClawConfig = {
avatar?: string;
};
};
secrets?: SecretsConfig;
skills?: SkillsConfig;
plugins?: PluginsConfig;
models?: ModelsConfig;

View File

@@ -0,0 +1,49 @@
export type SecretRefSource = "env" | "file";
/**
* Stable identifier for a secret in a configured source.
* Examples:
* - env source: "OPENAI_API_KEY"
* - file source: "/providers/openai/api_key" (JSON pointer)
*/
export type SecretRef = {
source: SecretRefSource;
id: string;
};
export type SecretInput = string | SecretRef;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isSecretRef(value: unknown): value is SecretRef {
if (!isRecord(value)) {
return false;
}
if (Object.keys(value).length !== 2) {
return false;
}
return (
(value.source === "env" || value.source === "file") &&
typeof value.id === "string" &&
value.id.trim().length > 0
);
}
export type EnvSecretSourceConfig = {
type?: "env";
};
export type SopsSecretSourceConfig = {
type: "sops";
path: string;
timeoutMs?: number;
};
export type SecretsConfig = {
sources?: {
env?: EnvSecretSourceConfig;
file?: SopsSecretSourceConfig;
};
};

View File

@@ -22,6 +22,7 @@ export * from "./types.msteams.js";
export * from "./types.plugins.js";
export * from "./types.queue.js";
export * from "./types.sandbox.js";
export * from "./types.secrets.js";
export * from "./types.signal.js";
export * from "./types.skills.js";
export * from "./types.slack.js";

View File

@@ -3,6 +3,49 @@ import { isSafeExecutableValue } from "../infra/exec-safety.js";
import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js";
import { sensitive } from "./zod-schema.sensitive.js";
const SECRET_REF_ID_PATTERN = /^[A-Za-z0-9_./:=-](?:[A-Za-z0-9_./:=~-]{0,127})$/;
export const SecretRefSchema = z
.object({
source: z.enum(["env", "file"]),
id: z
.string()
.regex(
SECRET_REF_ID_PATTERN,
"Secret reference id must match /^[A-Za-z0-9_./:=-](?:[A-Za-z0-9_./:=~-]{0,127})$/",
),
})
.strict();
export const SecretInputSchema = z.union([z.string(), SecretRefSchema]);
const SecretsEnvSourceSchema = z
.object({
type: z.literal("env").optional(),
})
.strict();
const SecretsFileSourceSchema = z
.object({
type: z.literal("sops"),
path: z.string().min(1),
timeoutMs: z.number().int().positive().max(120000).optional(),
})
.strict();
export const SecretsConfigSchema = z
.object({
sources: z
.object({
env: SecretsEnvSourceSchema.optional(),
file: SecretsFileSourceSchema.optional(),
})
.strict()
.optional(),
})
.strict()
.optional();
export const ModelApiSchema = z.union([
z.literal("openai-completions"),
z.literal("openai-responses"),
@@ -58,7 +101,7 @@ export const ModelDefinitionSchema = z
export const ModelProviderSchema = z
.object({
baseUrl: z.string().min(1),
apiKey: z.string().optional().register(sensitive),
apiKey: SecretInputSchema.optional().register(sensitive),
auth: z
.union([z.literal("api-key"), z.literal("aws-sdk"), z.literal("oauth"), z.literal("token")])
.optional(),

View File

@@ -25,6 +25,7 @@ import {
MarkdownConfigSchema,
MSTeamsReplyStyleSchema,
ProviderCommandsSchema,
SecretRefSchema,
ReplyToModeSchema,
RetryConfigSchema,
TtsConfigSchema,
@@ -519,7 +520,11 @@ export const GoogleChatAccountSchema = z
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groups: z.record(z.string(), GoogleChatGroupSchema.optional()).optional(),
defaultTo: z.string().optional(),
serviceAccount: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
serviceAccount: z
.union([z.string(), z.record(z.string(), z.unknown()), SecretRefSchema])
.optional()
.register(sensitive),
serviceAccountRef: SecretRefSchema.optional().register(sensitive),
serviceAccountFile: z.string().optional(),
audienceType: z.enum(["app-url", "project-number"]).optional(),
audience: z.string().optional(),

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
import { ApprovalsSchema } from "./zod-schema.approvals.js";
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
import { HexColorSchema, ModelsConfigSchema, SecretsConfigSchema } from "./zod-schema.core.js";
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
import { InstallRecordShape } from "./zod-schema.installs.js";
import { ChannelsSchema } from "./zod-schema.providers.js";
@@ -263,6 +263,7 @@ export const OpenClawSchema = z
})
.strict()
.optional(),
secrets: SecretsConfigSchema,
auth: z
.object({
profiles: z

View File

@@ -250,7 +250,7 @@ export function startGatewayConfigReloader(opts: {
initialConfig: OpenClawConfig;
readSnapshot: () => Promise<ConfigFileSnapshot>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void;
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
log: {
info: (msg: string) => void;
warn: (msg: string) => void;
@@ -301,42 +301,53 @@ export function startGatewayConfigReloader(opts: {
}
const nextConfig = snapshot.config;
const changedPaths = diffConfigPaths(currentConfig, nextConfig);
currentConfig = nextConfig;
settings = resolveGatewayReloadSettings(nextConfig);
const nextSettings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) {
currentConfig = nextConfig;
settings = nextSettings;
return;
}
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
const plan = buildGatewayReloadPlan(changedPaths);
if (settings.mode === "off") {
if (nextSettings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");
currentConfig = nextConfig;
settings = nextSettings;
return;
}
if (settings.mode === "restart") {
if (nextSettings.mode === "restart") {
if (!restartQueued) {
restartQueued = true;
opts.onRestart(plan, nextConfig);
await opts.onRestart(plan, nextConfig);
}
currentConfig = nextConfig;
settings = nextSettings;
return;
}
if (plan.restartGateway) {
if (settings.mode === "hot") {
if (nextSettings.mode === "hot") {
opts.log.warn(
`config reload requires gateway restart; hot mode ignoring (${plan.restartReasons.join(
", ",
)})`,
);
currentConfig = nextConfig;
settings = nextSettings;
return;
}
if (!restartQueued) {
restartQueued = true;
opts.onRestart(plan, nextConfig);
await opts.onRestart(plan, nextConfig);
}
currentConfig = nextConfig;
settings = nextSettings;
return;
}
await opts.onHotReload(plan, nextConfig);
currentConfig = nextConfig;
settings = nextSettings;
} catch (err) {
opts.log.error(`config reload failed: ${String(err)}`);
} finally {

View File

@@ -11,6 +11,7 @@ import { createDefaultDeps } from "../cli/deps.js";
import { isRestartEnabled } from "../config/commands.js";
import {
CONFIG_PATH,
type OpenClawConfig,
isNixMode,
loadConfig,
migrateLegacyConfig,
@@ -45,6 +46,12 @@ import { createEmptyPluginRegistry } from "../plugins/registry.js";
import type { PluginServicesHandle } from "../plugins/services.js";
import { getTotalQueueSize } from "../process/command-queue.js";
import type { RuntimeEnv } from "../runtime.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
} from "../secrets/runtime.js";
import { runOnboardingWizard } from "../wizard/onboarding.js";
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
import { startChannelHealthMonitor } from "./channel-health-monitor.js";
@@ -107,6 +114,7 @@ const logReload = log.child("reload");
const logHooks = log.child("hooks");
const logPlugins = log.child("plugins");
const logWsControl = log.child("ws");
const logSecrets = log.child("secrets");
const gatewayRuntime = runtimeForLogger(log);
const canvasRuntime = runtimeForLogger(logCanvas);
@@ -233,7 +241,63 @@ export async function startGatewayServer(
}
}
let cfgAtStart = loadConfig();
let secretsDegraded = false;
const activateRuntimeSecrets = async (
config: OpenClawConfig,
params: { reason: "startup" | "reload" | "restart-check"; activate: boolean },
) => {
try {
const prepared = await prepareSecretsRuntimeSnapshot({ config });
if (params.activate) {
activateSecretsRuntimeSnapshot(prepared);
}
for (const warning of prepared.warnings) {
logSecrets.warn(`[${warning.code}] ${warning.message}`);
}
if (secretsDegraded) {
logSecrets.info(
"[SECRETS_RELOADER_RECOVERED] Secret resolution recovered; runtime remained on last-known-good during the outage.",
);
}
secretsDegraded = false;
return prepared;
} catch (err) {
const details = String(err);
if (!secretsDegraded) {
logSecrets.error(`[SECRETS_RELOADER_DEGRADED] ${details}`);
} else {
logSecrets.warn(`[SECRETS_RELOADER_DEGRADED] ${details}`);
}
secretsDegraded = true;
if (params.reason === "startup") {
throw new Error(`Startup failed: required secrets are unavailable. ${details}`, {
cause: err,
});
}
throw err;
}
};
// Fail fast before startup if required refs are unresolved.
let cfgAtStart: OpenClawConfig;
{
const freshSnapshot = await readConfigFileSnapshot();
if (!freshSnapshot.valid) {
const issues =
freshSnapshot.issues.length > 0
? freshSnapshot.issues
.map((issue) => `${issue.path || "<root>"}: ${issue.message}`)
.join("\n")
: "Unknown validation issue.";
throw new Error(`Invalid config at ${freshSnapshot.path}.\n${issues}`);
}
await activateRuntimeSecrets(freshSnapshot.config, {
reason: "startup",
activate: false,
});
}
cfgAtStart = loadConfig();
const authBootstrap = await ensureGatewayStartupAuth({
cfg: cfgAtStart,
env: process.env,
@@ -253,6 +317,12 @@ export async function startGatewayServer(
);
}
}
cfgAtStart = (
await activateRuntimeSecrets(cfgAtStart, {
reason: "startup",
activate: true,
})
).config;
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
if (diagnosticsEnabled) {
startDiagnosticHeartbeat();
@@ -710,8 +780,27 @@ export async function startGatewayServer(
return startGatewayConfigReloader({
initialConfig: cfgAtStart,
readSnapshot: readConfigFileSnapshot,
onHotReload: applyHotReload,
onRestart: requestGatewayRestart,
onHotReload: async (plan, nextConfig) => {
const previousSnapshot = getActiveSecretsRuntimeSnapshot();
const prepared = await activateRuntimeSecrets(nextConfig, {
reason: "reload",
activate: true,
});
try {
await applyHotReload(plan, prepared.config);
} catch (err) {
if (previousSnapshot) {
activateSecretsRuntimeSnapshot(previousSnapshot);
} else {
clearSecretsRuntimeSnapshot();
}
throw err;
}
},
onRestart: async (plan, nextConfig) => {
await activateRuntimeSecrets(nextConfig, { reason: "restart-check", activate: false });
requestGatewayRestart(plan, nextConfig);
},
log: {
info: (msg) => logReload.info(msg),
warn: (msg) => logReload.warn(msg),
@@ -764,6 +853,7 @@ export async function startGatewayServer(
skillsChangeUnsub();
authRateLimiter?.dispose();
channelHealthMonitor?.stop();
clearSecretsRuntimeSnapshot();
await close(opts);
},
};

View File

@@ -0,0 +1,94 @@
function failOrUndefined(params: { onMissing: "throw" | "undefined"; message: string }): undefined {
if (params.onMissing === "throw") {
throw new Error(params.message);
}
return undefined;
}
export function decodeJsonPointerToken(token: string): string {
return token.replace(/~1/g, "/").replace(/~0/g, "~");
}
export function encodeJsonPointerToken(token: string): string {
return token.replace(/~/g, "~0").replace(/\//g, "~1");
}
export function readJsonPointer(
root: unknown,
pointer: string,
options: { onMissing?: "throw" | "undefined" } = {},
): unknown {
const onMissing = options.onMissing ?? "throw";
if (!pointer.startsWith("/")) {
return failOrUndefined({
onMissing,
message:
'File-backed secret ids must be absolute JSON pointers (for example: "/providers/openai/apiKey").',
});
}
const tokens = pointer
.slice(1)
.split("/")
.map((token) => decodeJsonPointerToken(token));
let current: unknown = root;
for (const token of tokens) {
if (Array.isArray(current)) {
const index = Number.parseInt(token, 10);
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
return failOrUndefined({
onMissing,
message: `JSON pointer segment "${token}" is out of bounds.`,
});
}
current = current[index];
continue;
}
if (typeof current !== "object" || current === null || Array.isArray(current)) {
return failOrUndefined({
onMissing,
message: `JSON pointer segment "${token}" does not exist.`,
});
}
const record = current as Record<string, unknown>;
if (!Object.hasOwn(record, token)) {
return failOrUndefined({
onMissing,
message: `JSON pointer segment "${token}" does not exist.`,
});
}
current = record[token];
}
return current;
}
export function setJsonPointer(
root: Record<string, unknown>,
pointer: string,
value: unknown,
): void {
if (!pointer.startsWith("/")) {
throw new Error(`Invalid JSON pointer "${pointer}".`);
}
const tokens = pointer
.slice(1)
.split("/")
.map((token) => decodeJsonPointerToken(token));
let current: Record<string, unknown> = root;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
const isLast = index === tokens.length - 1;
if (isLast) {
current[token] = value;
return;
}
const child = current[token];
if (typeof child !== "object" || child === null || Array.isArray(child)) {
current[token] = {};
}
current = current[token] as Record<string, unknown>;
}
}

View File

@@ -0,0 +1,28 @@
export const PROVIDER_ENV_VARS: Record<string, readonly string[]> = {
openai: ["OPENAI_API_KEY"],
anthropic: ["ANTHROPIC_API_KEY"],
google: ["GEMINI_API_KEY"],
minimax: ["MINIMAX_API_KEY"],
"minimax-cn": ["MINIMAX_API_KEY"],
moonshot: ["MOONSHOT_API_KEY"],
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"],
synthetic: ["SYNTHETIC_API_KEY"],
venice: ["VENICE_API_KEY"],
zai: ["ZAI_API_KEY", "Z_AI_API_KEY"],
xiaomi: ["XIAOMI_API_KEY"],
openrouter: ["OPENROUTER_API_KEY"],
"cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"],
litellm: ["LITELLM_API_KEY"],
"vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
together: ["TOGETHER_API_KEY"],
huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"],
qianfan: ["QIANFAN_API_KEY"],
xai: ["XAI_API_KEY"],
volcengine: ["VOLCANO_ENGINE_API_KEY"],
byteplus: ["BYTEPLUS_API_KEY"],
};
export function listKnownSecretEnvVarNames(): string[] {
return [...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys))];
}

159
src/secrets/runtime.test.ts Normal file
View File

@@ -0,0 +1,159 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
} from "./runtime.js";
const runExecMock = vi.hoisted(() => vi.fn());
vi.mock("../process/exec.js", () => ({
runExec: runExecMock,
}));
describe("secrets runtime snapshot", () => {
afterEach(() => {
runExecMock.mockReset();
clearSecretsRuntimeSnapshot();
});
it("resolves env refs for config and auth profiles", async () => {
const config: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "OPENAI_API_KEY" },
models: [],
},
},
},
};
const snapshot = await prepareSecretsRuntimeSnapshot({
config,
env: {
OPENAI_API_KEY: "sk-env-openai",
GITHUB_TOKEN: "ghp-env-token",
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "old-openai",
keyRef: { source: "env", id: "OPENAI_API_KEY" },
},
"github-copilot:default": {
type: "token",
provider: "github-copilot",
token: "old-gh",
tokenRef: { source: "env", id: "GITHUB_TOKEN" },
},
},
}),
});
expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-env-openai");
expect(snapshot.warnings).toHaveLength(2);
expect(snapshot.authStores[0]?.store.profiles["openai:default"]).toMatchObject({
type: "api_key",
key: "sk-env-openai",
});
expect(snapshot.authStores[0]?.store.profiles["github-copilot:default"]).toMatchObject({
type: "token",
token: "ghp-env-token",
});
});
it("resolves file refs via sops json payload", async () => {
runExecMock.mockResolvedValueOnce({
stdout: JSON.stringify({
providers: {
openai: {
apiKey: "sk-from-sops",
},
},
}),
stderr: "",
});
const config: OpenClawConfig = {
secrets: {
sources: {
file: {
type: "sops",
path: "~/.openclaw/secrets.enc.json",
timeoutMs: 7000,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", id: "/providers/openai/apiKey" },
models: [],
},
},
},
};
const snapshot = await prepareSecretsRuntimeSnapshot({
config,
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-from-sops");
expect(runExecMock).toHaveBeenCalledWith(
"sops",
["--decrypt", "--output-type", "json", expect.stringContaining("secrets.enc.json")],
{
timeoutMs: 7000,
maxBuffer: 10 * 1024 * 1024,
},
);
});
it("activates runtime snapshots for loadConfig and ensureAuthProfileStore", async () => {
const prepared = await prepareSecretsRuntimeSnapshot({
config: {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "OPENAI_API_KEY" },
models: [],
},
},
},
},
env: { OPENAI_API_KEY: "sk-runtime" },
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", id: "OPENAI_API_KEY" },
},
},
}),
});
activateSecretsRuntimeSnapshot(prepared);
expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-runtime");
const store = ensureAuthProfileStore("/tmp/openclaw-agent-main");
expect(store.profiles["openai:default"]).toMatchObject({
type: "api_key",
key: "sk-runtime",
});
});
});

317
src/secrets/runtime.ts Normal file
View File

@@ -0,0 +1,317 @@
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js";
import type { AuthProfileCredential, AuthProfileStore } from "../agents/auth-profiles.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStoreForRuntime,
replaceRuntimeAuthProfileStoreSnapshots,
} from "../agents/auth-profiles.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../config/config.js";
import { isSecretRef, type SecretRef } from "../config/types.secrets.js";
import { resolveUserPath } from "../utils.js";
import { readJsonPointer } from "./json-pointer.js";
import { decryptSopsJsonFile, DEFAULT_SOPS_TIMEOUT_MS } from "./sops.js";
type SecretResolverWarningCode = "SECRETS_REF_OVERRIDES_PLAINTEXT";
export type SecretResolverWarning = {
code: SecretResolverWarningCode;
path: string;
message: string;
};
export type PreparedSecretsRuntimeSnapshot = {
sourceConfig: OpenClawConfig;
config: OpenClawConfig;
authStores: Array<{ agentDir: string; store: AuthProfileStore }>;
warnings: SecretResolverWarning[];
};
type ResolverContext = {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
fileSecretsPromise: Promise<unknown> | null;
};
type ProviderLike = {
apiKey?: unknown;
};
type GoogleChatAccountLike = {
serviceAccount?: unknown;
serviceAccountRef?: unknown;
accounts?: Record<string, unknown>;
};
type ApiKeyCredentialLike = AuthProfileCredential & {
type: "api_key";
key?: string;
keyRef?: unknown;
};
type TokenCredentialLike = AuthProfileCredential & {
type: "token";
token?: string;
tokenRef?: unknown;
};
let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null;
function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot {
return {
sourceConfig: structuredClone(snapshot.sourceConfig),
config: structuredClone(snapshot.config),
authStores: snapshot.authStores.map((entry) => ({
agentDir: entry.agentDir,
store: structuredClone(entry.store),
})),
warnings: snapshot.warnings.map((warning) => ({ ...warning })),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
async function decryptSopsFile(config: OpenClawConfig): Promise<unknown> {
const fileSource = config.secrets?.sources?.file;
if (!fileSource) {
throw new Error(
`Secret reference source "file" is not configured. Configure secrets.sources.file first.`,
);
}
if (fileSource.type !== "sops") {
throw new Error(`Unsupported secrets.sources.file.type "${String(fileSource.type)}".`);
}
const resolvedPath = resolveUserPath(fileSource.path);
const timeoutMs =
typeof fileSource.timeoutMs === "number" && Number.isFinite(fileSource.timeoutMs)
? Math.max(1, Math.floor(fileSource.timeoutMs))
: DEFAULT_SOPS_TIMEOUT_MS;
return await decryptSopsJsonFile({
path: resolvedPath,
timeoutMs,
missingBinaryMessage:
"sops binary not found in PATH. Install sops >= 3.9.0 or disable secrets.sources.file.",
});
}
async function resolveSecretRefValue(ref: SecretRef, context: ResolverContext): Promise<unknown> {
const id = ref.id.trim();
if (!id) {
throw new Error(`Secret reference id is empty.`);
}
if (ref.source === "env") {
const envValue = context.env[id];
if (!isNonEmptyString(envValue)) {
throw new Error(`Environment variable "${id}" is missing or empty.`);
}
return envValue;
}
if (ref.source === "file") {
context.fileSecretsPromise ??= decryptSopsFile(context.config);
const payload = await context.fileSecretsPromise;
return readJsonPointer(payload, id, { onMissing: "throw" });
}
throw new Error(`Unsupported secret source "${String((ref as { source?: unknown }).source)}".`);
}
async function resolveGoogleChatServiceAccount(
target: GoogleChatAccountLike,
path: string,
context: ResolverContext,
warnings: SecretResolverWarning[],
): Promise<void> {
const explicitRef = isSecretRef(target.serviceAccountRef) ? target.serviceAccountRef : null;
const inlineRef = isSecretRef(target.serviceAccount) ? target.serviceAccount : null;
const ref = explicitRef ?? inlineRef;
if (!ref) {
return;
}
if (explicitRef && target.serviceAccount !== undefined && !isSecretRef(target.serviceAccount)) {
warnings.push({
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
path,
message: `${path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`,
});
}
target.serviceAccount = await resolveSecretRefValue(ref, context);
}
async function resolveConfigSecretRefs(params: {
config: OpenClawConfig;
context: ResolverContext;
warnings: SecretResolverWarning[];
}): Promise<OpenClawConfig> {
const resolved = structuredClone(params.config);
const providers = resolved.models?.providers as Record<string, ProviderLike> | undefined;
if (providers) {
for (const [providerId, provider] of Object.entries(providers)) {
if (!isSecretRef(provider.apiKey)) {
continue;
}
const resolvedValue = await resolveSecretRefValue(provider.apiKey, params.context);
if (!isNonEmptyString(resolvedValue)) {
throw new Error(
`models.providers.${providerId}.apiKey resolved to a non-string or empty value.`,
);
}
provider.apiKey = resolvedValue;
}
}
const googleChat = resolved.channels?.googlechat as GoogleChatAccountLike | undefined;
if (googleChat) {
await resolveGoogleChatServiceAccount(
googleChat,
"channels.googlechat",
params.context,
params.warnings,
);
if (isRecord(googleChat.accounts)) {
for (const [accountId, account] of Object.entries(googleChat.accounts)) {
if (!isRecord(account)) {
continue;
}
await resolveGoogleChatServiceAccount(
account as GoogleChatAccountLike,
`channels.googlechat.accounts.${accountId}`,
params.context,
params.warnings,
);
}
}
}
return resolved;
}
async function resolveAuthStoreSecretRefs(params: {
store: AuthProfileStore;
context: ResolverContext;
warnings: SecretResolverWarning[];
agentDir: string;
}): Promise<AuthProfileStore> {
const resolvedStore = structuredClone(params.store);
for (const [profileId, profile] of Object.entries(resolvedStore.profiles)) {
if (profile.type === "api_key") {
const apiProfile = profile as ApiKeyCredentialLike;
const keyRef = isSecretRef(apiProfile.keyRef) ? apiProfile.keyRef : null;
if (keyRef && isNonEmptyString(apiProfile.key)) {
params.warnings.push({
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
path: `${params.agentDir}.auth-profiles.${profileId}.key`,
message: `auth-profiles ${profileId}: keyRef is set; runtime will ignore plaintext key.`,
});
}
if (keyRef) {
const resolvedValue = await resolveSecretRefValue(keyRef, params.context);
if (!isNonEmptyString(resolvedValue)) {
throw new Error(`auth profile "${profileId}" keyRef resolved to an empty value.`);
}
apiProfile.key = resolvedValue;
}
continue;
}
if (profile.type === "token") {
const tokenProfile = profile as TokenCredentialLike;
const tokenRef = isSecretRef(tokenProfile.tokenRef) ? tokenProfile.tokenRef : null;
if (tokenRef && isNonEmptyString(tokenProfile.token)) {
params.warnings.push({
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
path: `${params.agentDir}.auth-profiles.${profileId}.token`,
message: `auth-profiles ${profileId}: tokenRef is set; runtime will ignore plaintext token.`,
});
}
if (tokenRef) {
const resolvedValue = await resolveSecretRefValue(tokenRef, params.context);
if (!isNonEmptyString(resolvedValue)) {
throw new Error(`auth profile "${profileId}" tokenRef resolved to an empty value.`);
}
tokenProfile.token = resolvedValue;
}
}
}
return resolvedStore;
}
function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
const dirs = new Set<string>();
dirs.add(resolveUserPath(resolveOpenClawAgentDir()));
for (const agentId of listAgentIds(config)) {
dirs.add(resolveUserPath(resolveAgentDir(config, agentId)));
}
return [...dirs];
}
export async function prepareSecretsRuntimeSnapshot(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
agentDirs?: string[];
loadAuthStore?: (agentDir?: string) => AuthProfileStore;
}): Promise<PreparedSecretsRuntimeSnapshot> {
const warnings: SecretResolverWarning[] = [];
const context: ResolverContext = {
config: params.config,
env: params.env ?? process.env,
fileSecretsPromise: null,
};
const resolvedConfig = await resolveConfigSecretRefs({
config: params.config,
context,
warnings,
});
const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForRuntime;
const candidateDirs = params.agentDirs?.length
? [...new Set(params.agentDirs.map((entry) => resolveUserPath(entry)))]
: collectCandidateAgentDirs(resolvedConfig);
const authStores: Array<{ agentDir: string; store: AuthProfileStore }> = [];
for (const agentDir of candidateDirs) {
const rawStore = loadAuthStore(agentDir);
const resolvedStore = await resolveAuthStoreSecretRefs({
store: rawStore,
context,
warnings,
agentDir,
});
authStores.push({ agentDir, store: resolvedStore });
}
return {
sourceConfig: structuredClone(params.config),
config: resolvedConfig,
authStores,
warnings,
};
}
export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): void {
const next = cloneSnapshot(snapshot);
setRuntimeConfigSnapshot(next.config, next.sourceConfig);
replaceRuntimeAuthProfileStoreSnapshots(next.authStores);
activeSnapshot = next;
}
export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null {
return activeSnapshot ? cloneSnapshot(activeSnapshot) : null;
}
export function clearSecretsRuntimeSnapshot(): void {
activeSnapshot = null;
clearRuntimeConfigSnapshot();
clearRuntimeAuthProfileStoreSnapshots();
}

120
src/secrets/sops.ts Normal file
View File

@@ -0,0 +1,120 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { runExec } from "../process/exec.js";
export const DEFAULT_SOPS_TIMEOUT_MS = 5_000;
const MAX_SOPS_OUTPUT_BYTES = 10 * 1024 * 1024;
function normalizeTimeoutMs(value: number | undefined): number {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(1, Math.floor(value));
}
return DEFAULT_SOPS_TIMEOUT_MS;
}
function isTimeoutError(message: string | undefined): boolean {
return typeof message === "string" && message.toLowerCase().includes("timed out");
}
type SopsErrorContext = {
missingBinaryMessage: string;
operationLabel: string;
};
function toSopsError(err: unknown, params: SopsErrorContext): Error {
const error = err as NodeJS.ErrnoException & { message?: string };
if (error.code === "ENOENT") {
return new Error(params.missingBinaryMessage, { cause: err });
}
return new Error(`${params.operationLabel}: ${String(error.message ?? err)}`, {
cause: err,
});
}
function ensureDirForFile(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
}
export async function decryptSopsJsonFile(params: {
path: string;
timeoutMs?: number;
missingBinaryMessage: string;
}): Promise<unknown> {
const timeoutMs = normalizeTimeoutMs(params.timeoutMs);
try {
const { stdout } = await runExec("sops", ["--decrypt", "--output-type", "json", params.path], {
timeoutMs,
maxBuffer: MAX_SOPS_OUTPUT_BYTES,
});
return JSON.parse(stdout) as unknown;
} catch (err) {
const error = err as NodeJS.ErrnoException & { message?: string };
if (isTimeoutError(error.message)) {
throw new Error(`sops decrypt timed out after ${timeoutMs}ms for ${params.path}.`, {
cause: err,
});
}
throw toSopsError(err, {
missingBinaryMessage: params.missingBinaryMessage,
operationLabel: `sops decrypt failed for ${params.path}`,
});
}
}
export async function encryptSopsJsonFile(params: {
path: string;
payload: Record<string, unknown>;
timeoutMs?: number;
missingBinaryMessage: string;
}): Promise<void> {
ensureDirForFile(params.path);
const timeoutMs = normalizeTimeoutMs(params.timeoutMs);
const tmpPlain = path.join(
path.dirname(params.path),
`${path.basename(params.path)}.${process.pid}.${crypto.randomUUID()}.plain.tmp`,
);
const tmpEncrypted = path.join(
path.dirname(params.path),
`${path.basename(params.path)}.${process.pid}.${crypto.randomUUID()}.enc.tmp`,
);
fs.writeFileSync(tmpPlain, `${JSON.stringify(params.payload, null, 2)}\n`, "utf8");
fs.chmodSync(tmpPlain, 0o600);
try {
await runExec(
"sops",
[
"--encrypt",
"--input-type",
"json",
"--output-type",
"json",
"--output",
tmpEncrypted,
tmpPlain,
],
{
timeoutMs,
maxBuffer: MAX_SOPS_OUTPUT_BYTES,
},
);
fs.renameSync(tmpEncrypted, params.path);
fs.chmodSync(params.path, 0o600);
} catch (err) {
const error = err as NodeJS.ErrnoException & { message?: string };
if (isTimeoutError(error.message)) {
throw new Error(`sops encrypt timed out after ${timeoutMs}ms for ${params.path}.`, {
cause: err,
});
}
throw toSopsError(err, {
missingBinaryMessage: params.missingBinaryMessage,
operationLabel: `sops encrypt failed for ${params.path}`,
});
} finally {
fs.rmSync(tmpPlain, { force: true });
fs.rmSync(tmpEncrypted, { force: true });
}
}