feat(security): add provider-based external secrets management

This commit is contained in:
joshavant
2026-02-25 17:39:31 -06:00
committed by Peter Steinberger
parent bb60cab76d
commit 4e7a833a24
35 changed files with 1779 additions and 669 deletions

View File

@@ -1,15 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const runExecMock = vi.hoisted(() => vi.fn());
vi.mock("../process/exec.js", () => ({
runExec: runExecMock,
}));
const { rollbackSecretsMigration, runSecretsMigration } = await import("./migrate.js");
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { rollbackSecretsMigration, runSecretsMigration } from "./migrate.js";
describe("secrets migrate", () => {
let baseDir = "";
@@ -91,28 +84,6 @@ describe("secrets migrate", () => {
"OPENAI_API_KEY=sk-openai-plaintext\nSKILL_KEY=sk-skill-plaintext\nUNRELATED=value\n",
"utf8",
);
runExecMock.mockReset();
runExecMock.mockImplementation(async (_cmd: string, args: string[]) => {
if (args.includes("--encrypt")) {
const outputPath = args[args.indexOf("--output") + 1];
const inputPath = args.at(-1);
if (!outputPath || !inputPath) {
throw new Error("missing sops encrypt paths");
}
await fs.copyFile(inputPath, outputPath);
return { stdout: "", stderr: "" };
}
if (args.includes("--decrypt")) {
const sourcePath = args.at(-1);
if (!sourcePath) {
throw new Error("missing sops decrypt source");
}
const raw = await fs.readFile(sourcePath, "utf8");
return { stdout: raw, stderr: "" };
}
throw new Error(`unexpected sops invocation: ${args.join(" ")}`);
});
});
afterEach(async () => {
@@ -144,22 +115,25 @@ describe("secrets migrate", () => {
models: { providers: { openai: { apiKey: unknown } } };
skills: { entries: { "review-pr": { apiKey: unknown } } };
channels: { googlechat: { serviceAccount?: unknown; serviceAccountRef?: unknown } };
secrets: { sources: { file: { type: string; path: string } } };
secrets: { providers: Record<string, { source: string; path: string }> };
};
expect(migratedConfig.models.providers.openai.apiKey).toEqual({
source: "file",
provider: "default",
id: "/providers/openai/apiKey",
});
expect(migratedConfig.skills.entries["review-pr"].apiKey).toEqual({
source: "file",
provider: "default",
id: "/skills/entries/review-pr/apiKey",
});
expect(migratedConfig.channels.googlechat.serviceAccount).toBeUndefined();
expect(migratedConfig.channels.googlechat.serviceAccountRef).toEqual({
source: "file",
provider: "default",
id: "/channels/googlechat/serviceAccount",
});
expect(migratedConfig.secrets.sources.file.type).toBe("sops");
expect(migratedConfig.secrets.providers.default.source).toBe("file");
const migratedAuth = JSON.parse(await fs.readFile(authStorePath, "utf8")) as {
profiles: { "openai:default": { key?: string; keyRef?: unknown } };
@@ -167,6 +141,7 @@ describe("secrets migrate", () => {
expect(migratedAuth.profiles["openai:default"].key).toBeUndefined();
expect(migratedAuth.profiles["openai:default"].keyRef).toEqual({
source: "file",
provider: "default",
id: "/auth-profiles/main/openai:default/key",
});
@@ -175,7 +150,7 @@ describe("secrets migrate", () => {
expect(migratedEnv).toContain("SKILL_KEY=sk-skill-plaintext");
expect(migratedEnv).toContain("UNRELATED=value");
const secretsPath = path.join(stateDir, "secrets.enc.json");
const secretsPath = path.join(stateDir, "secrets.json");
const secretsPayload = JSON.parse(await fs.readFile(secretsPath, "utf8")) as {
providers: { openai: { apiKey: string } };
skills: { entries: { "review-pr": { apiKey: string } } };
@@ -214,45 +189,53 @@ describe("secrets migrate", () => {
expect(second.backupId).not.toBe(first.backupId);
});
it("passes --config for sops when .sops.yaml exists in config dir", async () => {
const sopsConfigPath = path.join(stateDir, ".sops.yaml");
await fs.writeFile(sopsConfigPath, "creation_rules:\n - path_regex: .*\n", "utf8");
it("reuses configured file provider aliases", async () => {
await fs.writeFile(
configPath,
`${JSON.stringify(
{
secrets: {
providers: {
teamfile: {
source: "file",
path: "~/.openclaw/team-secrets.json",
mode: "jsonPointer",
},
},
defaults: {
file: "teamfile",
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-openai-plaintext",
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
},
null,
2,
)}\n`,
"utf8",
);
await runSecretsMigration({ env, write: true });
const encryptCall = runExecMock.mock.calls.find((call) =>
(call[1] as string[]).includes("--encrypt"),
);
expect(encryptCall).toBeTruthy();
const args = encryptCall?.[1] as string[];
const configIndex = args.indexOf("--config");
expect(configIndex).toBeGreaterThanOrEqual(0);
expect(args[configIndex + 1]).toBe(sopsConfigPath);
const filenameOverrideIndex = args.indexOf("--filename-override");
expect(filenameOverrideIndex).toBeGreaterThanOrEqual(0);
expect(args[filenameOverrideIndex + 1]).toBe(
path.join(stateDir, "secrets.enc.json").replaceAll(path.sep, "/"),
);
const options = encryptCall?.[2] as { cwd?: string } | undefined;
expect(options?.cwd).toBe(stateDir);
const migratedConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
models: { providers: { openai: { apiKey: unknown } } };
};
expect(migratedConfig.models.providers.openai.apiKey).toEqual({
source: "file",
provider: "teamfile",
id: "/providers/openai/apiKey",
});
});
it("passes a stable filename override for sops when config file is absent", async () => {
await runSecretsMigration({ env, write: true });
const encryptCall = runExecMock.mock.calls.find((call) =>
(call[1] as string[]).includes("--encrypt"),
);
expect(encryptCall).toBeTruthy();
const args = encryptCall?.[1] as string[];
const configIndex = args.indexOf("--config");
expect(configIndex).toBe(-1);
const filenameOverrideIndex = args.indexOf("--filename-override");
expect(filenameOverrideIndex).toBeGreaterThanOrEqual(0);
expect(args[filenameOverrideIndex + 1]).toBe(
path.join(stateDir, "secrets.enc.json").replaceAll(path.sep, "/"),
);
const options = encryptCall?.[2] as { cwd?: string } | undefined;
expect(options?.cwd).toBe(stateDir);
it("keeps .env values when scrub-env is disabled", async () => {
await runSecretsMigration({ env, write: true, scrubEnv: false });
const migratedEnv = await fs.readFile(envPath, "utf8");
expect(migratedEnv).toContain("OPENAI_API_KEY=sk-openai-plaintext");
});
});

View File

@@ -1,6 +1,5 @@
import fs from "node:fs";
import { ensureDirForFile, writeJsonFileSecure } from "../shared.js";
import { encryptSopsJsonFile } from "../sops.js";
import {
createBackupManifest,
pruneOldBackups,
@@ -10,22 +9,6 @@ import {
import { createSecretsMigrationConfigIO } from "./config-io.js";
import type { MigrationPlan, SecretsMigrationRunResult } from "./types.js";
async function encryptSopsJson(params: {
pathname: string;
timeoutMs: number;
payload: Record<string, unknown>;
sopsConfigPath?: string;
}): Promise<void> {
await encryptSopsJsonFile({
path: params.pathname,
payload: params.payload,
timeoutMs: params.timeoutMs,
configPath: params.sopsConfigPath,
missingBinaryMessage:
"sops binary not found in PATH. Install sops >= 3.9.0 to run secrets migrate.",
});
}
export async function applyMigrationPlan(params: {
plan: MigrationPlan;
env: NodeJS.ProcessEnv;
@@ -52,12 +35,7 @@ export async function applyMigrationPlan(params: {
try {
if (plan.payloadChanged) {
await encryptSopsJson({
pathname: plan.secretsFilePath,
timeoutMs: plan.secretsFileTimeoutMs,
payload: plan.nextPayload,
sopsConfigPath: plan.sopsConfigPath,
});
writeJsonFileSecure(plan.secretsFilePath, plan.nextPayload);
}
if (plan.configChanged) {

View File

@@ -6,7 +6,7 @@ import { isDeepStrictEqual } from "node:util";
import { listAgentIds, resolveAgentDir } from "../../agents/agent-scope.js";
import { resolveAuthStorePath } from "../../agents/auth-profiles/paths.js";
import { resolveStateDir, type OpenClawConfig } from "../../config/config.js";
import { isSecretRef } from "../../config/types.secrets.js";
import { coerceSecretRef, DEFAULT_SECRET_PROVIDER_ALIAS } from "../../config/types.secrets.js";
import { resolveConfigDir, resolveUserPath } from "../../utils.js";
import {
encodeJsonPointerToken,
@@ -14,12 +14,11 @@ import {
setJsonPointer,
} from "../json-pointer.js";
import { listKnownSecretEnvVarNames } from "../provider-env-vars.js";
import { isNonEmptyString, isRecord, normalizePositiveInt } from "../shared.js";
import { decryptSopsJsonFile, DEFAULT_SOPS_TIMEOUT_MS } from "../sops.js";
import { isNonEmptyString, isRecord } from "../shared.js";
import { createSecretsMigrationConfigIO } from "./config-io.js";
import type { AuthStoreChange, EnvChange, MigrationCounters, MigrationPlan } from "./types.js";
const DEFAULT_SECRETS_FILE_PATH = "~/.openclaw/secrets.enc.json";
const DEFAULT_SECRETS_FILE_PATH = "~/.openclaw/secrets.json";
function readJsonPointer(root: unknown, pointer: string): unknown {
return readJsonPointerRaw(root, pointer, { onMissing: "undefined" });
@@ -83,70 +82,67 @@ function resolveFileSource(
config: OpenClawConfig,
env: NodeJS.ProcessEnv,
): {
providerName: string;
path: string;
timeoutMs: number;
hadConfiguredSource: boolean;
hadConfiguredProvider: boolean;
} {
const source = config.secrets?.sources?.file;
if (source && source.type === "sops" && isNonEmptyString(source.path)) {
return {
path: resolveUserPath(source.path),
timeoutMs: normalizePositiveInt(source.timeoutMs, DEFAULT_SOPS_TIMEOUT_MS),
hadConfiguredSource: true,
};
const configuredProviders = config.secrets?.providers;
const defaultProviderName =
config.secrets?.defaults?.file?.trim() || DEFAULT_SECRET_PROVIDER_ALIAS;
if (configuredProviders) {
const defaultProvider = configuredProviders[defaultProviderName];
if (defaultProvider?.source === "file" && isNonEmptyString(defaultProvider.path)) {
return {
providerName: defaultProviderName,
path: resolveUserPath(defaultProvider.path),
hadConfiguredProvider: true,
};
}
for (const [providerName, provider] of Object.entries(configuredProviders)) {
if (provider?.source === "file" && isNonEmptyString(provider.path)) {
return {
providerName,
path: resolveUserPath(provider.path),
hadConfiguredProvider: true,
};
}
}
}
return {
providerName: defaultProviderName,
path: resolveUserPath(resolveDefaultSecretsConfigPath(env)),
timeoutMs: DEFAULT_SOPS_TIMEOUT_MS,
hadConfiguredSource: false,
hadConfiguredProvider: false,
};
}
function resolveDefaultSecretsConfigPath(env: NodeJS.ProcessEnv): string {
if (env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim()) {
return path.join(resolveStateDir(env, os.homedir), "secrets.enc.json");
return path.join(resolveStateDir(env, os.homedir), "secrets.json");
}
return DEFAULT_SECRETS_FILE_PATH;
}
async function decryptSopsJson(
pathname: string,
timeoutMs: number,
sopsConfigPath?: string,
): Promise<Record<string, unknown>> {
async function readSecretsFileJson(pathname: string): Promise<Record<string, unknown>> {
if (!fs.existsSync(pathname)) {
return {};
}
const parsed = await decryptSopsJsonFile({
path: pathname,
timeoutMs,
configPath: sopsConfigPath,
missingBinaryMessage:
"sops binary not found in PATH. Install sops >= 3.9.0 to run secrets migrate.",
});
const raw = fs.readFileSync(pathname, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
throw new Error("sops decrypt failed: decrypted payload is not a JSON object");
throw new Error("Secrets file payload is not a JSON object.");
}
return parsed;
}
function resolveExistingSopsConfigPath(env: NodeJS.ProcessEnv): string | undefined {
const configDir = resolveConfigDir(env, os.homedir);
const candidates = [".sops.yaml", ".sops.yml"].map((name) => path.join(configDir, name));
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return undefined;
}
function migrateModelProviderSecrets(params: {
config: OpenClawConfig;
payload: Record<string, unknown>;
counters: MigrationCounters;
migratedValues: Set<string>;
fileProviderName: string;
}): void {
const providers = params.config.models?.providers as
| Record<string, { apiKey?: unknown }>
@@ -155,7 +151,7 @@ function migrateModelProviderSecrets(params: {
return;
}
for (const [providerId, provider] of Object.entries(providers)) {
if (isSecretRef(provider.apiKey)) {
if (coerceSecretRef(provider.apiKey)) {
continue;
}
if (!isNonEmptyString(provider.apiKey)) {
@@ -168,7 +164,7 @@ function migrateModelProviderSecrets(params: {
setJsonPointer(params.payload, id, value);
params.counters.secretsWritten += 1;
}
provider.apiKey = { source: "file", id };
provider.apiKey = { source: "file", provider: params.fileProviderName, id };
params.counters.configRefs += 1;
params.migratedValues.add(value);
}
@@ -179,13 +175,14 @@ function migrateSkillEntrySecrets(params: {
payload: Record<string, unknown>;
counters: MigrationCounters;
migratedValues: Set<string>;
fileProviderName: string;
}): void {
const entries = params.config.skills?.entries as Record<string, { apiKey?: unknown }> | undefined;
if (!entries) {
return;
}
for (const [skillKey, entry] of Object.entries(entries)) {
if (!isRecord(entry) || isSecretRef(entry.apiKey)) {
if (!isRecord(entry) || coerceSecretRef(entry.apiKey)) {
continue;
}
if (!isNonEmptyString(entry.apiKey)) {
@@ -198,7 +195,7 @@ function migrateSkillEntrySecrets(params: {
setJsonPointer(params.payload, id, value);
params.counters.secretsWritten += 1;
}
entry.apiKey = { source: "file", id };
entry.apiKey = { source: "file", provider: params.fileProviderName, id };
params.counters.configRefs += 1;
params.migratedValues.add(value);
}
@@ -209,17 +206,14 @@ function migrateGoogleChatServiceAccount(params: {
pointerId: string;
counters: MigrationCounters;
payload: Record<string, unknown>;
fileProviderName: string;
}): void {
const explicitRef = isSecretRef(params.account.serviceAccountRef)
? params.account.serviceAccountRef
: null;
const inlineRef = isSecretRef(params.account.serviceAccount)
? params.account.serviceAccount
: null;
const explicitRef = coerceSecretRef(params.account.serviceAccountRef);
const inlineRef = coerceSecretRef(params.account.serviceAccount);
if (explicitRef || inlineRef) {
if (
params.account.serviceAccount !== undefined &&
!isSecretRef(params.account.serviceAccount)
!coerceSecretRef(params.account.serviceAccount)
) {
delete params.account.serviceAccount;
params.counters.plaintextRemoved += 1;
@@ -242,7 +236,11 @@ function migrateGoogleChatServiceAccount(params: {
params.counters.secretsWritten += 1;
}
params.account.serviceAccountRef = { source: "file", id };
params.account.serviceAccountRef = {
source: "file",
provider: params.fileProviderName,
id,
};
delete params.account.serviceAccount;
params.counters.configRefs += 1;
}
@@ -251,6 +249,7 @@ function migrateGoogleChatSecrets(params: {
config: OpenClawConfig;
payload: Record<string, unknown>;
counters: MigrationCounters;
fileProviderName: string;
}): void {
const googlechat = params.config.channels?.googlechat;
if (!isRecord(googlechat)) {
@@ -262,6 +261,7 @@ function migrateGoogleChatSecrets(params: {
pointerId: "/channels/googlechat",
payload: params.payload,
counters: params.counters,
fileProviderName: params.fileProviderName,
});
if (!isRecord(googlechat.accounts)) {
@@ -276,6 +276,7 @@ function migrateGoogleChatSecrets(params: {
pointerId: `/channels/googlechat/accounts/${encodeJsonPointerToken(accountId)}`,
payload: params.payload,
counters: params.counters,
fileProviderName: params.fileProviderName,
});
}
}
@@ -325,6 +326,7 @@ function migrateAuthStoreSecrets(params: {
payload: Record<string, unknown>;
counters: MigrationCounters;
migratedValues: Set<string>;
fileProviderName: string;
}): boolean {
const profiles = params.store.profiles;
if (!isRecord(profiles)) {
@@ -337,7 +339,7 @@ function migrateAuthStoreSecrets(params: {
continue;
}
if (profileValue.type === "api_key") {
const keyRef = isSecretRef(profileValue.keyRef) ? profileValue.keyRef : null;
const keyRef = coerceSecretRef(profileValue.keyRef);
const key = isNonEmptyString(profileValue.key) ? profileValue.key.trim() : "";
if (keyRef) {
if (key) {
@@ -356,7 +358,7 @@ function migrateAuthStoreSecrets(params: {
setJsonPointer(params.payload, id, key);
params.counters.secretsWritten += 1;
}
profileValue.keyRef = { source: "file", id };
profileValue.keyRef = { source: "file", provider: params.fileProviderName, id };
delete profileValue.key;
params.counters.authProfileRefs += 1;
params.migratedValues.add(key);
@@ -365,7 +367,7 @@ function migrateAuthStoreSecrets(params: {
}
if (profileValue.type === "token") {
const tokenRef = isSecretRef(profileValue.tokenRef) ? profileValue.tokenRef : null;
const tokenRef = coerceSecretRef(profileValue.tokenRef);
const token = isNonEmptyString(profileValue.token) ? profileValue.token.trim() : "";
if (tokenRef) {
if (token) {
@@ -384,7 +386,7 @@ function migrateAuthStoreSecrets(params: {
setJsonPointer(params.payload, id, token);
params.counters.secretsWritten += 1;
}
profileValue.tokenRef = { source: "file", id };
profileValue.tokenRef = { source: "file", provider: params.fileProviderName, id };
delete profileValue.token;
params.counters.authProfileRefs += 1;
params.migratedValues.add(token);
@@ -412,12 +414,7 @@ export async function buildMigrationPlan(params: {
const stateDir = resolveStateDir(params.env, os.homedir);
const nextConfig = structuredClone(snapshot.config);
const fileSource = resolveFileSource(nextConfig, params.env);
const sopsConfigPath = resolveExistingSopsConfigPath(params.env);
const previousPayload = await decryptSopsJson(
fileSource.path,
fileSource.timeoutMs,
sopsConfigPath,
);
const previousPayload = await readSecretsFileJson(fileSource.path);
const nextPayload = structuredClone(previousPayload);
const counters: MigrationCounters = {
@@ -436,17 +433,20 @@ export async function buildMigrationPlan(params: {
payload: nextPayload,
counters,
migratedValues,
fileProviderName: fileSource.providerName,
});
migrateSkillEntrySecrets({
config: nextConfig,
payload: nextPayload,
counters,
migratedValues,
fileProviderName: fileSource.providerName,
});
migrateGoogleChatSecrets({
config: nextConfig,
payload: nextPayload,
counters,
fileProviderName: fileSource.providerName,
});
const authStoreChanges: AuthStoreChange[] = [];
@@ -473,6 +473,7 @@ export async function buildMigrationPlan(params: {
payload: nextPayload,
counters,
migratedValues,
fileProviderName: fileSource.providerName,
});
if (!changed) {
continue;
@@ -481,15 +482,17 @@ export async function buildMigrationPlan(params: {
}
counters.authStoresChanged = authStoreChanges.length;
if (counters.secretsWritten > 0 && !fileSource.hadConfiguredSource) {
if (counters.secretsWritten > 0 && !fileSource.hadConfiguredProvider) {
const defaultConfigPath = resolveDefaultSecretsConfigPath(params.env);
nextConfig.secrets ??= {};
nextConfig.secrets.sources ??= {};
nextConfig.secrets.sources.file = {
type: "sops",
nextConfig.secrets.providers ??= {};
nextConfig.secrets.providers[fileSource.providerName] = {
source: "file",
path: defaultConfigPath,
timeoutMs: DEFAULT_SOPS_TIMEOUT_MS,
mode: "jsonPointer",
};
nextConfig.secrets.defaults ??= {};
nextConfig.secrets.defaults.file ??= fileSource.providerName;
}
const configChanged = !isDeepStrictEqual(snapshot.config, nextConfig);
@@ -536,8 +539,6 @@ export async function buildMigrationPlan(params: {
payloadChanged,
nextPayload,
secretsFilePath: fileSource.path,
secretsFileTimeoutMs: fileSource.timeoutMs,
sopsConfigPath,
envChange,
backupTargets: [...backupTargets],
};

View File

@@ -45,8 +45,6 @@ export type MigrationPlan = {
payloadChanged: boolean;
nextPayload: Record<string, unknown>;
secretsFilePath: string;
secretsFileTimeoutMs: number;
sopsConfigPath?: string;
envChange: EnvChange | null;
backupTargets: string[];
};

154
src/secrets/resolve.test.ts Normal file
View File

@@ -0,0 +1,154 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js";
async function writeSecureFile(filePath: string, content: string, mode = 0o600): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf8");
await fs.chmod(filePath, mode);
}
describe("secret ref resolver", () => {
const cleanupRoots: string[] = [];
afterEach(async () => {
while (cleanupRoots.length > 0) {
const root = cleanupRoots.pop();
if (!root) {
continue;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("resolves env refs via implicit default env provider", async () => {
const config: OpenClawConfig = {};
const value = await resolveSecretRefString(
{ source: "env", provider: "default", id: "OPENAI_API_KEY" },
{
config,
env: { OPENAI_API_KEY: "sk-env-value" },
},
);
expect(value).toBe("sk-env-value");
});
it("resolves file refs in jsonPointer mode", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-file-"));
cleanupRoots.push(root);
const filePath = path.join(root, "secrets.json");
await writeSecureFile(
filePath,
JSON.stringify({
providers: {
openai: {
apiKey: "sk-file-value",
},
},
}),
);
const value = await resolveSecretRefString(
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
{
config: {
secrets: {
providers: {
filemain: {
source: "file",
path: filePath,
mode: "jsonPointer",
},
},
},
},
},
);
expect(value).toBe("sk-file-value");
});
it("resolves exec refs with protocolVersion 1 response", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-exec-"));
cleanupRoots.push(root);
const scriptPath = path.join(root, "resolver.mjs");
await writeSecureFile(
scriptPath,
[
"#!/usr/bin/env node",
"import fs from 'node:fs';",
"const req = JSON.parse(fs.readFileSync(0, 'utf8'));",
"const values = Object.fromEntries((req.ids ?? []).map((id) => [id, `value:${id}`]));",
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values }));",
].join("\n"),
0o700,
);
const value = await resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: scriptPath,
passEnv: ["PATH"],
},
},
},
},
},
);
expect(value).toBe("value:openai/api-key");
});
it("supports file raw mode with id=value", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-raw-"));
cleanupRoots.push(root);
const filePath = path.join(root, "token.txt");
await writeSecureFile(filePath, "raw-token-value\n");
const value = await resolveSecretRefString(
{ source: "file", provider: "rawfile", id: "value" },
{
config: {
secrets: {
providers: {
rawfile: {
source: "file",
path: filePath,
mode: "raw",
},
},
},
},
},
);
expect(value).toBe("raw-token-value");
});
it("rejects misconfigured provider source mismatches", async () => {
await expect(
resolveSecretRefValue(
{ source: "exec", provider: "default", id: "abc" },
{
config: {
secrets: {
providers: {
default: {
source: "env",
},
},
},
},
},
),
).rejects.toThrow('has source "env" but ref requests "exec"');
});
});

View File

@@ -1,79 +1,674 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import type { SecretRef } from "../config/types.secrets.js";
import type {
ExecSecretProviderConfig,
FileSecretProviderConfig,
SecretProviderConfig,
SecretRef,
SecretRefSource,
} from "../config/types.secrets.js";
import { DEFAULT_SECRET_PROVIDER_ALIAS } from "../config/types.secrets.js";
import { inspectPathPermissions, safeStat } from "../security/audit-fs.js";
import { isPathInside } from "../security/scan-paths.js";
import { resolveUserPath } from "../utils.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { readJsonPointer } from "./json-pointer.js";
import { isNonEmptyString, isRecord, normalizePositiveInt } from "./shared.js";
import { decryptSopsJsonFile, DEFAULT_SOPS_TIMEOUT_MS } from "./sops.js";
const DEFAULT_PROVIDER_CONCURRENCY = 4;
const DEFAULT_MAX_REFS_PER_PROVIDER = 512;
const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
const DEFAULT_FILE_MAX_BYTES = 1024 * 1024;
const DEFAULT_FILE_TIMEOUT_MS = 5_000;
const DEFAULT_EXEC_TIMEOUT_MS = 5_000;
const DEFAULT_EXEC_NO_OUTPUT_TIMEOUT_MS = 2_000;
const DEFAULT_EXEC_MAX_OUTPUT_BYTES = 1024 * 1024;
const RAW_FILE_REF_ID = "value";
const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
export type SecretRefResolveCache = {
fileSecretsPromise?: Promise<unknown> | null;
resolvedByRefKey?: Map<string, Promise<unknown>>;
filePayloadByProvider?: Map<string, Promise<unknown>>;
};
type ResolveSecretRefOptions = {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
cache?: SecretRefResolveCache;
missingBinaryMessage?: string;
};
const DEFAULT_SOPS_MISSING_BINARY_MESSAGE =
"sops binary not found in PATH. Install sops >= 3.9.0 or disable secrets.sources.file.";
type ResolutionLimits = {
maxProviderConcurrency: number;
maxRefsPerProvider: number;
maxBatchBytes: number;
};
async function resolveFileSecretPayload(options: ResolveSecretRefOptions): Promise<unknown> {
const fileSource = options.config.secrets?.sources?.file;
if (!fileSource) {
type ProviderResolutionOutput = Map<string, unknown>;
function isAbsolutePathname(value: string): boolean {
return (
path.isAbsolute(value) ||
WINDOWS_ABS_PATH_PATTERN.test(value) ||
WINDOWS_UNC_PATH_PATTERN.test(value)
);
}
function resolveSourceDefaultAlias(source: SecretRefSource, config: OpenClawConfig): string {
const configured =
source === "env"
? config.secrets?.defaults?.env
: source === "file"
? config.secrets?.defaults?.file
: config.secrets?.defaults?.exec;
return configured?.trim() || DEFAULT_SECRET_PROVIDER_ALIAS;
}
function resolveResolutionLimits(config: OpenClawConfig): ResolutionLimits {
const resolution = config.secrets?.resolution;
return {
maxProviderConcurrency: normalizePositiveInt(
resolution?.maxProviderConcurrency,
DEFAULT_PROVIDER_CONCURRENCY,
),
maxRefsPerProvider: normalizePositiveInt(
resolution?.maxRefsPerProvider,
DEFAULT_MAX_REFS_PER_PROVIDER,
),
maxBatchBytes: normalizePositiveInt(resolution?.maxBatchBytes, DEFAULT_MAX_BATCH_BYTES),
};
}
function toRefKey(ref: SecretRef): string {
return `${ref.source}:${ref.provider}:${ref.id}`;
}
function toProviderKey(source: SecretRefSource, provider: string): string {
return `${source}:${provider}`;
}
function resolveConfiguredProvider(ref: SecretRef, config: OpenClawConfig): SecretProviderConfig {
const providerConfig = config.secrets?.providers?.[ref.provider];
if (!providerConfig) {
if (ref.source === "env" && ref.provider === resolveSourceDefaultAlias("env", config)) {
return { source: "env" };
}
throw new Error(
'Secret reference source "file" is not configured. Configure secrets.sources.file first.',
`Secret provider "${ref.provider}" is not configured (ref: ${ref.source}:${ref.provider}:${ref.id}).`,
);
}
if (fileSource.type !== "sops") {
throw new Error(`Unsupported secrets.sources.file.type "${String(fileSource.type)}".`);
if (providerConfig.source !== ref.source) {
throw new Error(
`Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "${ref.source}".`,
);
}
return providerConfig;
}
const cache = options.cache;
if (cache?.fileSecretsPromise) {
return await cache.fileSecretsPromise;
async function assertSecurePath(params: {
targetPath: string;
label: string;
trustedDirs?: string[];
allowInsecurePath?: boolean;
allowReadableByOthers?: boolean;
}): Promise<void> {
if (!isAbsolutePathname(params.targetPath)) {
throw new Error(`${params.label} must be an absolute path.`);
}
const promise = decryptSopsJsonFile({
path: resolveUserPath(fileSource.path),
timeoutMs: normalizePositiveInt(fileSource.timeoutMs, DEFAULT_SOPS_TIMEOUT_MS),
missingBinaryMessage: options.missingBinaryMessage ?? DEFAULT_SOPS_MISSING_BINARY_MESSAGE,
}).then((payload) => {
if (!isRecord(payload)) {
throw new Error("sops decrypt failed: decrypted payload is not a JSON object");
if (params.trustedDirs && params.trustedDirs.length > 0) {
const trusted = params.trustedDirs.map((entry) => resolveUserPath(entry));
const inTrustedDir = trusted.some((dir) => isPathInside(dir, params.targetPath));
if (!inTrustedDir) {
throw new Error(`${params.label} is outside trustedDirs: ${params.targetPath}`);
}
return payload;
});
if (cache) {
cache.fileSecretsPromise = promise;
}
return await promise;
const stat = await safeStat(params.targetPath);
if (!stat.ok) {
throw new Error(`${params.label} is not readable: ${params.targetPath}`);
}
if (stat.isDir) {
throw new Error(`${params.label} must be a file: ${params.targetPath}`);
}
if (stat.isSymlink) {
throw new Error(`${params.label} must not be a symlink: ${params.targetPath}`);
}
if (params.allowInsecurePath) {
return;
}
const perms = await inspectPathPermissions(params.targetPath);
if (!perms.ok) {
throw new Error(`${params.label} permissions could not be verified: ${params.targetPath}`);
}
const writableByOthers = perms.worldWritable || perms.groupWritable;
const readableByOthers = perms.worldReadable || perms.groupReadable;
if (writableByOthers || (!params.allowReadableByOthers && readableByOthers)) {
throw new Error(`${params.label} permissions are too open: ${params.targetPath}`);
}
if (process.platform === "win32" && perms.source === "unknown") {
throw new Error(
`${params.label} ACL verification unavailable on Windows for ${params.targetPath}.`,
);
}
if (process.platform !== "win32" && typeof process.getuid === "function" && stat.uid != null) {
const uid = process.getuid();
if (stat.uid !== uid) {
throw new Error(
`${params.label} must be owned by the current user (uid=${uid}): ${params.targetPath}`,
);
}
}
}
async function readFileProviderPayload(params: {
providerName: string;
providerConfig: FileSecretProviderConfig;
cache?: SecretRefResolveCache;
}): Promise<unknown> {
const cacheKey = params.providerName;
const cache = params.cache;
if (cache?.filePayloadByProvider?.has(cacheKey)) {
return await (cache.filePayloadByProvider.get(cacheKey) as Promise<unknown>);
}
const filePath = resolveUserPath(params.providerConfig.path);
const readPromise = (async () => {
await assertSecurePath({
targetPath: filePath,
label: `secrets.providers.${params.providerName}.path`,
});
const timeoutMs = normalizePositiveInt(
params.providerConfig.timeoutMs,
DEFAULT_FILE_TIMEOUT_MS,
);
const maxBytes = normalizePositiveInt(params.providerConfig.maxBytes, DEFAULT_FILE_MAX_BYTES);
const timeoutHandle = setTimeout(() => {
// noop marker to keep timeout behavior explicit and deterministic
}, timeoutMs);
try {
const payload = await fs.readFile(filePath);
if (payload.byteLength > maxBytes) {
throw new Error(`File provider "${params.providerName}" exceeded maxBytes (${maxBytes}).`);
}
const text = payload.toString("utf8");
if (params.providerConfig.mode === "raw") {
return text.replace(/\r?\n$/, "");
}
const parsed = JSON.parse(text) as unknown;
if (!isRecord(parsed)) {
throw new Error(`File provider "${params.providerName}" payload is not a JSON object.`);
}
return parsed;
} finally {
clearTimeout(timeoutHandle);
}
})();
if (cache) {
cache.filePayloadByProvider ??= new Map();
cache.filePayloadByProvider.set(cacheKey, readPromise);
}
return await readPromise;
}
async function resolveEnvRefs(params: {
refs: SecretRef[];
providerName: string;
providerConfig: Extract<SecretProviderConfig, { source: "env" }>;
env: NodeJS.ProcessEnv;
}): Promise<ProviderResolutionOutput> {
const resolved = new Map<string, unknown>();
const allowlist = params.providerConfig.allowlist
? new Set(params.providerConfig.allowlist)
: null;
for (const ref of params.refs) {
if (allowlist && !allowlist.has(ref.id)) {
throw new Error(
`Environment variable "${ref.id}" is not allowlisted in secrets.providers.${params.providerName}.allowlist.`,
);
}
const envValue = params.env[ref.id] ?? process.env[ref.id];
if (!isNonEmptyString(envValue)) {
throw new Error(`Environment variable "${ref.id}" is missing or empty.`);
}
resolved.set(ref.id, envValue);
}
return resolved;
}
async function resolveFileRefs(params: {
refs: SecretRef[];
providerName: string;
providerConfig: FileSecretProviderConfig;
cache?: SecretRefResolveCache;
}): Promise<ProviderResolutionOutput> {
const payload = await readFileProviderPayload({
providerName: params.providerName,
providerConfig: params.providerConfig,
cache: params.cache,
});
const mode = params.providerConfig.mode ?? "jsonPointer";
const resolved = new Map<string, unknown>();
if (mode === "raw") {
for (const ref of params.refs) {
if (ref.id !== RAW_FILE_REF_ID) {
throw new Error(
`Raw file provider "${params.providerName}" expects ref id "${RAW_FILE_REF_ID}".`,
);
}
resolved.set(ref.id, payload);
}
return resolved;
}
for (const ref of params.refs) {
resolved.set(ref.id, readJsonPointer(payload, ref.id, { onMissing: "throw" }));
}
return resolved;
}
type ExecRunResult = {
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
termination: "exit" | "timeout" | "no-output-timeout";
};
async function runExecResolver(params: {
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
input: string;
timeoutMs: number;
noOutputTimeoutMs: number;
maxOutputBytes: number;
}): Promise<ExecRunResult> {
return await new Promise((resolve, reject) => {
const child = spawn(params.command, params.args, {
cwd: params.cwd,
env: params.env,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
windowsHide: true,
});
let settled = false;
let stdout = "";
let stderr = "";
let timedOut = false;
let noOutputTimedOut = false;
let outputBytes = 0;
let noOutputTimer: NodeJS.Timeout | null = null;
const timeoutTimer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, params.timeoutMs);
const clearTimers = () => {
clearTimeout(timeoutTimer);
if (noOutputTimer) {
clearTimeout(noOutputTimer);
noOutputTimer = null;
}
};
const armNoOutputTimer = () => {
if (noOutputTimer) {
clearTimeout(noOutputTimer);
}
noOutputTimer = setTimeout(() => {
noOutputTimedOut = true;
child.kill("SIGKILL");
}, params.noOutputTimeoutMs);
};
const append = (chunk: Buffer | string, target: "stdout" | "stderr") => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
outputBytes += Buffer.byteLength(text, "utf8");
if (outputBytes > params.maxOutputBytes) {
child.kill("SIGKILL");
if (!settled) {
settled = true;
clearTimers();
reject(
new Error(`Exec provider output exceeded maxOutputBytes (${params.maxOutputBytes}).`),
);
}
return;
}
if (target === "stdout") {
stdout += text;
} else {
stderr += text;
}
armNoOutputTimer();
};
armNoOutputTimer();
child.on("error", (error) => {
if (settled) {
return;
}
settled = true;
clearTimers();
reject(error);
});
child.stdout?.on("data", (chunk) => append(chunk, "stdout"));
child.stderr?.on("data", (chunk) => append(chunk, "stderr"));
child.on("close", (code, signal) => {
if (settled) {
return;
}
settled = true;
clearTimers();
resolve({
stdout,
stderr,
code,
signal,
termination: noOutputTimedOut ? "no-output-timeout" : timedOut ? "timeout" : "exit",
});
});
child.stdin?.end(params.input);
});
}
function parseExecValues(params: {
providerName: string;
ids: string[];
stdout: string;
jsonOnly: boolean;
}): Record<string, unknown> {
const trimmed = params.stdout.trim();
if (!trimmed) {
throw new Error(`Exec provider "${params.providerName}" returned empty stdout.`);
}
let parsed: unknown;
if (!params.jsonOnly && params.ids.length === 1) {
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
return { [params.ids[0]]: trimmed };
}
} else {
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
throw new Error(`Exec provider "${params.providerName}" returned invalid JSON.`);
}
}
if (!isRecord(parsed)) {
if (!params.jsonOnly && params.ids.length === 1 && typeof parsed === "string") {
return { [params.ids[0]]: parsed };
}
throw new Error(`Exec provider "${params.providerName}" response must be an object.`);
}
if (parsed.protocolVersion !== 1) {
throw new Error(`Exec provider "${params.providerName}" protocolVersion must be 1.`);
}
const responseValues = parsed.values;
if (!isRecord(responseValues)) {
throw new Error(`Exec provider "${params.providerName}" response missing "values".`);
}
const responseErrors = isRecord(parsed.errors) ? parsed.errors : null;
const out: Record<string, unknown> = {};
for (const id of params.ids) {
if (responseErrors && id in responseErrors) {
const entry = responseErrors[id];
if (isRecord(entry) && typeof entry.message === "string" && entry.message.trim()) {
throw new Error(
`Exec provider "${params.providerName}" failed for id "${id}" (${entry.message.trim()}).`,
);
}
throw new Error(`Exec provider "${params.providerName}" failed for id "${id}".`);
}
if (!(id in responseValues)) {
throw new Error(`Exec provider "${params.providerName}" response missing id "${id}".`);
}
out[id] = responseValues[id];
}
return out;
}
async function resolveExecRefs(params: {
refs: SecretRef[];
providerName: string;
providerConfig: ExecSecretProviderConfig;
env: NodeJS.ProcessEnv;
limits: ResolutionLimits;
}): Promise<ProviderResolutionOutput> {
const ids = [...new Set(params.refs.map((ref) => ref.id))];
if (ids.length > params.limits.maxRefsPerProvider) {
throw new Error(
`Exec provider "${params.providerName}" exceeded maxRefsPerProvider (${params.limits.maxRefsPerProvider}).`,
);
}
const commandPath = resolveUserPath(params.providerConfig.command);
await assertSecurePath({
targetPath: commandPath,
label: `secrets.providers.${params.providerName}.command`,
trustedDirs: params.providerConfig.trustedDirs,
allowInsecurePath: params.providerConfig.allowInsecurePath,
allowReadableByOthers: true,
});
const requestPayload = {
protocolVersion: 1,
provider: params.providerName,
ids,
};
const input = JSON.stringify(requestPayload);
if (Buffer.byteLength(input, "utf8") > params.limits.maxBatchBytes) {
throw new Error(
`Exec provider "${params.providerName}" request exceeded maxBatchBytes (${params.limits.maxBatchBytes}).`,
);
}
const childEnv: NodeJS.ProcessEnv = {};
for (const key of params.providerConfig.passEnv ?? []) {
const value = params.env[key] ?? process.env[key];
if (value !== undefined) {
childEnv[key] = value;
}
}
for (const [key, value] of Object.entries(params.providerConfig.env ?? {})) {
childEnv[key] = value;
}
const timeoutMs = normalizePositiveInt(params.providerConfig.timeoutMs, DEFAULT_EXEC_TIMEOUT_MS);
const noOutputTimeoutMs = normalizePositiveInt(
params.providerConfig.noOutputTimeoutMs,
DEFAULT_EXEC_NO_OUTPUT_TIMEOUT_MS,
);
const maxOutputBytes = normalizePositiveInt(
params.providerConfig.maxOutputBytes,
DEFAULT_EXEC_MAX_OUTPUT_BYTES,
);
const jsonOnly = params.providerConfig.jsonOnly ?? true;
const result = await runExecResolver({
command: commandPath,
args: params.providerConfig.args ?? [],
cwd: path.dirname(commandPath),
env: childEnv,
input,
timeoutMs,
noOutputTimeoutMs,
maxOutputBytes,
});
if (result.termination === "timeout") {
throw new Error(`Exec provider "${params.providerName}" timed out after ${timeoutMs}ms.`);
}
if (result.termination === "no-output-timeout") {
throw new Error(
`Exec provider "${params.providerName}" produced no output for ${noOutputTimeoutMs}ms.`,
);
}
if (result.code !== 0) {
throw new Error(
`Exec provider "${params.providerName}" exited with code ${String(result.code)}.`,
);
}
const values = parseExecValues({
providerName: params.providerName,
ids,
stdout: result.stdout,
jsonOnly,
});
const resolved = new Map<string, unknown>();
for (const id of ids) {
resolved.set(id, values[id]);
}
return resolved;
}
async function resolveProviderRefs(params: {
refs: SecretRef[];
source: SecretRefSource;
providerName: string;
providerConfig: SecretProviderConfig;
options: ResolveSecretRefOptions;
limits: ResolutionLimits;
}): Promise<ProviderResolutionOutput> {
if (params.providerConfig.source === "env") {
return await resolveEnvRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
env: params.options.env ?? process.env,
});
}
if (params.providerConfig.source === "file") {
return await resolveFileRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
cache: params.options.cache,
});
}
if (params.providerConfig.source === "exec") {
return await resolveExecRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
env: params.options.env ?? process.env,
limits: params.limits,
});
}
throw new Error(
`Unsupported secret provider source "${String((params.providerConfig as { source?: unknown }).source)}".`,
);
}
export async function resolveSecretRefValues(
refs: SecretRef[],
options: ResolveSecretRefOptions,
): Promise<Map<string, unknown>> {
if (refs.length === 0) {
return new Map();
}
const limits = resolveResolutionLimits(options.config);
const uniqueRefs = new Map<string, SecretRef>();
for (const ref of refs) {
const id = ref.id.trim();
if (!id) {
throw new Error("Secret reference id is empty.");
}
uniqueRefs.set(toRefKey(ref), { ...ref, id });
}
const grouped = new Map<
string,
{ source: SecretRefSource; providerName: string; refs: SecretRef[] }
>();
for (const ref of uniqueRefs.values()) {
const key = toProviderKey(ref.source, ref.provider);
const existing = grouped.get(key);
if (existing) {
existing.refs.push(ref);
continue;
}
grouped.set(key, { source: ref.source, providerName: ref.provider, refs: [ref] });
}
const tasks = [...grouped.values()].map(
(group) => async (): Promise<{ group: typeof group; values: ProviderResolutionOutput }> => {
if (group.refs.length > limits.maxRefsPerProvider) {
throw new Error(
`Secret provider "${group.providerName}" exceeded maxRefsPerProvider (${limits.maxRefsPerProvider}).`,
);
}
const providerConfig = resolveConfiguredProvider(group.refs[0], options.config);
const values = await resolveProviderRefs({
refs: group.refs,
source: group.source,
providerName: group.providerName,
providerConfig,
options,
limits,
});
return { group, values };
},
);
const taskResults = await runTasksWithConcurrency({
tasks,
limit: limits.maxProviderConcurrency,
errorMode: "stop",
});
if (taskResults.hasError) {
throw taskResults.firstError;
}
const resolved = new Map<string, unknown>();
for (const result of taskResults.results) {
for (const ref of result.group.refs) {
if (!result.values.has(ref.id)) {
throw new Error(
`Secret provider "${result.group.providerName}" did not return id "${ref.id}".`,
);
}
resolved.set(toRefKey(ref), result.values.get(ref.id));
}
}
return resolved;
}
export async function resolveSecretRefValue(
ref: SecretRef,
options: ResolveSecretRefOptions,
): Promise<unknown> {
const id = ref.id.trim();
if (!id) {
throw new Error("Secret reference id is empty.");
const cache = options.cache;
const key = toRefKey(ref);
if (cache?.resolvedByRefKey?.has(key)) {
return await (cache.resolvedByRefKey.get(key) as Promise<unknown>);
}
if (ref.source === "env") {
const envValue = options.env?.[id] ?? process.env[id];
if (!isNonEmptyString(envValue)) {
throw new Error(`Environment variable "${id}" is missing or empty.`);
const promise = (async () => {
const resolved = await resolveSecretRefValues([ref], options);
if (!resolved.has(key)) {
throw new Error(`Secret reference "${key}" resolved to no value.`);
}
return envValue;
}
return resolved.get(key);
})();
if (ref.source === "file") {
const payload = await resolveFileSecretPayload(options);
return readJsonPointer(payload, id, { onMissing: "throw" });
if (cache) {
cache.resolvedByRefKey ??= new Map();
cache.resolvedByRefKey.set(key, promise);
}
throw new Error(`Unsupported secret source "${String((ref as { source?: unknown }).source)}".`);
return await promise;
}
export async function resolveSecretRefString(
@@ -83,7 +678,7 @@ export async function resolveSecretRefString(
const resolved = await resolveSecretRefValue(ref, options);
if (!isNonEmptyString(resolved)) {
throw new Error(
`Secret reference "${ref.source}:${ref.id}" resolved to a non-string or empty value.`,
`Secret reference "${ref.source}:${ref.provider}:${ref.id}" resolved to a non-string or empty value.`,
);
}
return resolved;

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import {
@@ -10,15 +10,8 @@ import {
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();
});
@@ -28,7 +21,7 @@ describe("secrets runtime snapshot", () => {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "OPENAI_API_KEY" },
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
models: [],
},
},
@@ -37,7 +30,7 @@ describe("secrets runtime snapshot", () => {
entries: {
"review-pr": {
enabled: true,
apiKey: { source: "env", id: "REVIEW_SKILL_API_KEY" },
apiKey: { source: "env", provider: "default", id: "REVIEW_SKILL_API_KEY" },
},
},
},
@@ -58,13 +51,18 @@ describe("secrets runtime snapshot", () => {
type: "api_key",
provider: "openai",
key: "old-openai",
keyRef: { source: "env", id: "OPENAI_API_KEY" },
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
"github-copilot:default": {
type: "token",
provider: "github-copilot",
token: "old-gh",
tokenRef: { source: "env", id: "GITHUB_TOKEN" },
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
},
"openai:inline": {
type: "api_key",
provider: "openai",
key: "${OPENAI_API_KEY}",
},
},
}),
@@ -81,90 +79,105 @@ describe("secrets runtime snapshot", () => {
type: "token",
token: "ghp-env-token",
});
expect(snapshot.authStores[0]?.store.profiles["openai:inline"]).toMatchObject({
type: "api_key",
key: "sk-env-openai",
});
});
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")],
expect.objectContaining({
timeoutMs: 7000,
maxBuffer: 10 * 1024 * 1024,
cwd: expect.stringContaining(".openclaw"),
}),
);
});
it("fails when sops decrypt payload is not a JSON object", async () => {
runExecMock.mockResolvedValueOnce({
stdout: JSON.stringify(["not-an-object"]),
stderr: "",
});
await expect(
prepareSecretsRuntimeSnapshot({
config: {
secrets: {
sources: {
file: {
type: "sops",
path: "~/.openclaw/secrets.enc.json",
},
},
},
models: {
it("resolves file refs via configured file provider", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-file-provider-"));
const secretsPath = path.join(root, "secrets.json");
try {
await fs.writeFile(
secretsPath,
JSON.stringify(
{
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", id: "/providers/openai/apiKey" },
models: [],
apiKey: "sk-from-file-provider",
},
},
},
null,
2,
),
"utf8",
);
await fs.chmod(secretsPath, 0o600);
const config: OpenClawConfig = {
secrets: {
providers: {
default: {
source: "file",
path: secretsPath,
mode: "jsonPointer",
},
},
defaults: {
file: "default",
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
models: [],
},
},
},
};
const snapshot = await prepareSecretsRuntimeSnapshot({
config,
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
}),
).rejects.toThrow("sops decrypt failed: decrypted payload is not a JSON object");
});
expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-from-file-provider");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("fails when file provider payload is not a JSON object", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-file-provider-bad-"));
const secretsPath = path.join(root, "secrets.json");
try {
await fs.writeFile(secretsPath, JSON.stringify(["not-an-object"]), "utf8");
await fs.chmod(secretsPath, 0o600);
await expect(
prepareSecretsRuntimeSnapshot({
config: {
secrets: {
providers: {
default: {
source: "file",
path: secretsPath,
mode: "jsonPointer",
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
models: [],
},
},
},
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
}),
).rejects.toThrow("payload is not a JSON object");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("activates runtime snapshots for loadConfig and ensureAuthProfileStore", async () => {
@@ -174,7 +187,7 @@ describe("secrets runtime snapshot", () => {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "OPENAI_API_KEY" },
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
models: [],
},
},
@@ -188,7 +201,7 @@ describe("secrets runtime snapshot", () => {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", id: "OPENAI_API_KEY" },
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
},
}),
@@ -221,7 +234,7 @@ describe("secrets runtime snapshot", () => {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", id: "OPENAI_API_KEY" },
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
},
}),

View File

@@ -11,9 +11,9 @@ import {
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../config/config.js";
import { isSecretRef, type SecretRef } from "../config/types.secrets.js";
import { coerceSecretRef, type SecretRef } from "../config/types.secrets.js";
import { resolveUserPath } from "../utils.js";
import { resolveSecretRefValue, type SecretRefResolveCache } from "./resolve.js";
import { resolveSecretRefValues, type SecretRefResolveCache } from "./resolve.js";
import { isNonEmptyString, isRecord } from "./shared.js";
type SecretResolverWarningCode = "SECRETS_REF_OVERRIDES_PLAINTEXT";
@@ -31,11 +31,6 @@ export type PreparedSecretsRuntimeSnapshot = {
warnings: SecretResolverWarning[];
};
type ResolverContext = SecretRefResolveCache & {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
};
type ProviderLike = {
apiKey?: unknown;
};
@@ -62,8 +57,27 @@ type TokenCredentialLike = AuthProfileCredential & {
tokenRef?: unknown;
};
type SecretAssignment = {
ref: SecretRef;
path: string;
expected: "string" | "string-or-object";
apply: (value: unknown) => void;
};
type ResolverContext = {
sourceConfig: OpenClawConfig;
env: NodeJS.ProcessEnv;
cache: SecretRefResolveCache;
warnings: SecretResolverWarning[];
assignments: SecretAssignment[];
};
let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null;
function toRefKey(ref: SecretRef): string {
return `${ref.source}:${ref.provider}:${ref.id}`;
}
function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot {
return {
sourceConfig: structuredClone(snapshot.sourceConfig),
@@ -76,151 +90,174 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret
};
}
async function resolveSecretRefValueFromContext(
ref: SecretRef,
context: ResolverContext,
): Promise<unknown> {
return await resolveSecretRefValue(ref, {
config: context.config,
env: context.env,
cache: context,
});
}
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 resolveSecretRefValueFromContext(ref, context);
}
async function resolveConfigSecretRefs(params: {
function collectConfigAssignments(params: {
config: OpenClawConfig;
context: ResolverContext;
warnings: SecretResolverWarning[];
}): Promise<OpenClawConfig> {
const resolved = structuredClone(params.config);
const providers = resolved.models?.providers as Record<string, ProviderLike> | undefined;
}): void {
const defaults = params.context.sourceConfig.secrets?.defaults;
const providers = params.config.models?.providers as Record<string, ProviderLike> | undefined;
if (providers) {
for (const [providerId, provider] of Object.entries(providers)) {
if (!isSecretRef(provider.apiKey)) {
const ref = coerceSecretRef(provider.apiKey, defaults);
if (!ref) {
continue;
}
const resolvedValue = await resolveSecretRefValueFromContext(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;
params.context.assignments.push({
ref,
path: `models.providers.${providerId}.apiKey`,
expected: "string",
apply: (value) => {
provider.apiKey = value;
},
});
}
}
const skillEntries = resolved.skills?.entries as Record<string, SkillEntryLike> | undefined;
const skillEntries = params.config.skills?.entries as Record<string, SkillEntryLike> | undefined;
if (skillEntries) {
for (const [skillKey, entry] of Object.entries(skillEntries)) {
if (!isSecretRef(entry.apiKey)) {
const ref = coerceSecretRef(entry.apiKey, defaults);
if (!ref) {
continue;
}
const resolvedValue = await resolveSecretRefValueFromContext(entry.apiKey, params.context);
if (!isNonEmptyString(resolvedValue)) {
throw new Error(
`skills.entries.${skillKey}.apiKey resolved to a non-string or empty value.`,
);
}
entry.apiKey = resolvedValue;
params.context.assignments.push({
ref,
path: `skills.entries.${skillKey}.apiKey`,
expected: "string",
apply: (value) => {
entry.apiKey = value;
},
});
}
}
const googleChat = resolved.channels?.googlechat as GoogleChatAccountLike | undefined;
const collectGoogleChatAssignments = (target: GoogleChatAccountLike, path: string) => {
const explicitRef = coerceSecretRef(target.serviceAccountRef, defaults);
const inlineRef = coerceSecretRef(target.serviceAccount, defaults);
const ref = explicitRef ?? inlineRef;
if (!ref) {
return;
}
if (
explicitRef &&
target.serviceAccount !== undefined &&
!coerceSecretRef(target.serviceAccount, defaults)
) {
params.context.warnings.push({
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
path,
message: `${path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`,
});
}
params.context.assignments.push({
ref,
path: `${path}.serviceAccount`,
expected: "string-or-object",
apply: (value) => {
target.serviceAccount = value;
},
});
};
const googleChat = params.config.channels?.googlechat as GoogleChatAccountLike | undefined;
if (googleChat) {
await resolveGoogleChatServiceAccount(
googleChat,
"channels.googlechat",
params.context,
params.warnings,
);
collectGoogleChatAssignments(googleChat, "channels.googlechat");
if (isRecord(googleChat.accounts)) {
for (const [accountId, account] of Object.entries(googleChat.accounts)) {
if (!isRecord(account)) {
continue;
}
await resolveGoogleChatServiceAccount(
collectGoogleChatAssignments(
account as GoogleChatAccountLike,
`channels.googlechat.accounts.${accountId}`,
params.context,
params.warnings,
);
}
}
}
return resolved;
}
async function resolveAuthStoreSecretRefs(params: {
function collectAuthStoreAssignments(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)) {
}): void {
const defaults = params.context.sourceConfig.secrets?.defaults;
for (const [profileId, profile] of Object.entries(params.store.profiles)) {
if (profile.type === "api_key") {
const apiProfile = profile as ApiKeyCredentialLike;
const keyRef = isSecretRef(apiProfile.keyRef) ? apiProfile.keyRef : null;
const keyRef = coerceSecretRef(apiProfile.keyRef, defaults);
const inlineKeyRef = keyRef ? null : coerceSecretRef(apiProfile.key, defaults);
const resolvedKeyRef = keyRef ?? inlineKeyRef;
if (!resolvedKeyRef) {
continue;
}
if (keyRef && isNonEmptyString(apiProfile.key)) {
params.warnings.push({
params.context.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 resolveSecretRefValueFromContext(keyRef, params.context);
if (!isNonEmptyString(resolvedValue)) {
throw new Error(`auth profile "${profileId}" keyRef resolved to an empty value.`);
}
apiProfile.key = resolvedValue;
}
params.context.assignments.push({
ref: resolvedKeyRef,
path: `${params.agentDir}.auth-profiles.${profileId}.key`,
expected: "string",
apply: (value) => {
apiProfile.key = String(value);
},
});
continue;
}
if (profile.type === "token") {
const tokenProfile = profile as TokenCredentialLike;
const tokenRef = isSecretRef(tokenProfile.tokenRef) ? tokenProfile.tokenRef : null;
const tokenRef = coerceSecretRef(tokenProfile.tokenRef, defaults);
const inlineTokenRef = tokenRef ? null : coerceSecretRef(tokenProfile.token, defaults);
const resolvedTokenRef = tokenRef ?? inlineTokenRef;
if (!resolvedTokenRef) {
continue;
}
if (tokenRef && isNonEmptyString(tokenProfile.token)) {
params.warnings.push({
params.context.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 resolveSecretRefValueFromContext(tokenRef, params.context);
if (!isNonEmptyString(resolvedValue)) {
throw new Error(`auth profile "${profileId}" tokenRef resolved to an empty value.`);
}
tokenProfile.token = resolvedValue;
}
params.context.assignments.push({
ref: resolvedTokenRef,
path: `${params.agentDir}.auth-profiles.${profileId}.token`,
expected: "string",
apply: (value) => {
tokenProfile.token = String(value);
},
});
}
}
return resolvedStore;
}
function applyAssignments(params: {
assignments: SecretAssignment[];
resolved: Map<string, unknown>;
}): void {
for (const assignment of params.assignments) {
const key = toRefKey(assignment.ref);
if (!params.resolved.has(key)) {
throw new Error(`Secret reference "${key}" resolved to no value.`);
}
const value = params.resolved.get(key);
if (assignment.expected === "string") {
if (!isNonEmptyString(value)) {
throw new Error(`${assignment.path} resolved to a non-string or empty value.`);
}
assignment.apply(value);
continue;
}
if (!(isNonEmptyString(value) || isRecord(value))) {
throw new Error(`${assignment.path} resolved to an unsupported value type.`);
}
assignment.apply(value);
}
}
function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
@@ -238,39 +275,55 @@ export async function prepareSecretsRuntimeSnapshot(params: {
agentDirs?: string[];
loadAuthStore?: (agentDir?: string) => AuthProfileStore;
}): Promise<PreparedSecretsRuntimeSnapshot> {
const warnings: SecretResolverWarning[] = [];
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const context: ResolverContext = {
config: params.config,
sourceConfig,
env: params.env ?? process.env,
fileSecretsPromise: null,
cache: {},
warnings: [],
assignments: [],
};
const resolvedConfig = await resolveConfigSecretRefs({
config: params.config,
collectConfigAssignments({
config: resolvedConfig,
context,
warnings,
});
const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForSecretsRuntime;
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,
const store = structuredClone(loadAuthStore(agentDir));
collectAuthStoreAssignments({
store,
context,
warnings,
agentDir,
});
authStores.push({ agentDir, store: resolvedStore });
authStores.push({ agentDir, store });
}
if (context.assignments.length > 0) {
const refs = context.assignments.map((assignment) => assignment.ref);
const resolved = await resolveSecretRefValues(refs, {
config: sourceConfig,
env: context.env,
cache: context.cache,
});
applyAssignments({
assignments: context.assignments,
resolved,
});
}
return {
sourceConfig: structuredClone(params.config),
sourceConfig,
config: resolvedConfig,
authStores,
warnings,
warnings: context.warnings,
};
}

View File

@@ -1,152 +0,0 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { runExec } from "../process/exec.js";
import { ensureDirForFile, normalizePositiveInt } from "./shared.js";
export const DEFAULT_SOPS_TIMEOUT_MS = 5_000;
const MAX_SOPS_OUTPUT_BYTES = 10 * 1024 * 1024;
function toSopsPath(value: string): string {
return value.replaceAll(path.sep, "/");
}
function resolveFilenameOverride(params: { targetPath: string }): string {
return toSopsPath(path.resolve(params.targetPath));
}
function resolveSopsCwd(params: { targetPath: string; configPath?: string }): string {
if (typeof params.configPath === "string" && params.configPath.trim().length > 0) {
return path.dirname(params.configPath);
}
return path.dirname(params.targetPath);
}
function normalizeTimeoutMs(value: number | undefined): number {
return normalizePositiveInt(value, 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,
});
}
export async function decryptSopsJsonFile(params: {
path: string;
timeoutMs?: number;
missingBinaryMessage: string;
configPath?: string;
}): Promise<unknown> {
const timeoutMs = normalizeTimeoutMs(params.timeoutMs);
const cwd = resolveSopsCwd({
targetPath: params.path,
configPath: params.configPath,
});
try {
const args: string[] = [];
if (typeof params.configPath === "string" && params.configPath.trim().length > 0) {
args.push("--config", params.configPath);
}
args.push("--decrypt", "--output-type", "json", params.path);
const { stdout } = await runExec("sops", args, {
timeoutMs,
maxBuffer: MAX_SOPS_OUTPUT_BYTES,
cwd,
});
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;
configPath?: 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 {
const filenameOverride = resolveFilenameOverride({
targetPath: params.path,
});
const cwd = resolveSopsCwd({
targetPath: params.path,
configPath: params.configPath,
});
const args: string[] = [];
if (typeof params.configPath === "string" && params.configPath.trim().length > 0) {
args.push("--config", params.configPath);
}
args.push(
"--encrypt",
"--filename-override",
filenameOverride,
"--input-type",
"json",
"--output-type",
"json",
"--output",
tmpEncrypted,
tmpPlain,
);
await runExec("sops", args, {
timeoutMs,
maxBuffer: MAX_SOPS_OUTPUT_BYTES,
cwd,
});
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 });
}
}