feat(secrets): finalize external secrets runtime and migration hardening

This commit is contained in:
joshavant
2026-02-24 19:34:29 -06:00
committed by Peter Steinberger
parent c5b89fbaea
commit 0e69660c41
22 changed files with 442 additions and 38 deletions

View File

@@ -94,7 +94,7 @@ describe("secrets migrate", () => {
runExecMock.mockReset();
runExecMock.mockImplementation(async (_cmd: string, args: string[]) => {
if (args[0] === "--encrypt") {
if (args.includes("--encrypt")) {
const outputPath = args[args.indexOf("--output") + 1];
const inputPath = args.at(-1);
if (!outputPath || !inputPath) {
@@ -103,7 +103,7 @@ describe("secrets migrate", () => {
await fs.copyFile(inputPath, outputPath);
return { stdout: "", stderr: "" };
}
if (args[0] === "--decrypt") {
if (args.includes("--decrypt")) {
const sourcePath = args.at(-1);
if (!sourcePath) {
throw new Error("missing sops decrypt source");
@@ -213,4 +213,20 @@ describe("secrets migrate", () => {
expect(second.backupId).toBeTruthy();
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");
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);
});
});

View File

@@ -1,5 +1,4 @@
import fs from "node:fs";
import { createConfigIO } from "../../config/config.js";
import { ensureDirForFile, writeJsonFileSecure } from "../shared.js";
import { encryptSopsJsonFile } from "../sops.js";
import {
@@ -8,17 +7,20 @@ import {
resolveUniqueBackupId,
restoreFromManifest,
} from "./backup.js";
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.",
});
@@ -54,11 +56,12 @@ export async function applyMigrationPlan(params: {
pathname: plan.secretsFilePath,
timeoutMs: plan.secretsFileTimeoutMs,
payload: plan.nextPayload,
sopsConfigPath: plan.sopsConfigPath,
});
}
if (plan.configChanged) {
const io = createConfigIO({ env: params.env });
const io = createSecretsMigrationConfigIO({ env: params.env });
await io.writeConfigFile(plan.nextConfig, plan.configWriteOptions);
}

View File

@@ -0,0 +1,14 @@
import { createConfigIO } from "../../config/config.js";
const silentConfigIoLogger = {
error: () => {},
warn: () => {},
} as const;
export function createSecretsMigrationConfigIO(params: { env: NodeJS.ProcessEnv }) {
// Migration output is owned by the CLI command so --json remains machine-parseable.
return createConfigIO({
env: params.env,
logger: silentConfigIoLogger,
});
}

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { listAgentIds, resolveAgentDir } from "../../agents/agent-scope.js";
import { resolveAuthStorePath } from "../../agents/auth-profiles/paths.js";
import { createConfigIO, resolveStateDir, type OpenClawConfig } from "../../config/config.js";
import { resolveStateDir, type OpenClawConfig } from "../../config/config.js";
import { isSecretRef } from "../../config/types.secrets.js";
import { resolveConfigDir, resolveUserPath } from "../../utils.js";
import {
@@ -16,6 +16,7 @@ import {
import { listKnownSecretEnvVarNames } from "../provider-env-vars.js";
import { isNonEmptyString, isRecord, normalizePositiveInt } from "../shared.js";
import { decryptSopsJsonFile, DEFAULT_SOPS_TIMEOUT_MS } from "../sops.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";
@@ -112,6 +113,7 @@ function resolveDefaultSecretsConfigPath(env: NodeJS.ProcessEnv): string {
async function decryptSopsJson(
pathname: string,
timeoutMs: number,
sopsConfigPath?: string,
): Promise<Record<string, unknown>> {
if (!fs.existsSync(pathname)) {
return {};
@@ -119,6 +121,7 @@ async function decryptSopsJson(
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.",
});
@@ -128,6 +131,17 @@ async function decryptSopsJson(
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>;
@@ -385,7 +399,7 @@ export async function buildMigrationPlan(params: {
env: NodeJS.ProcessEnv;
scrubEnv: boolean;
}): Promise<MigrationPlan> {
const io = createConfigIO({ env: params.env });
const io = createSecretsMigrationConfigIO({ env: params.env });
const { snapshot, writeOptions } = await io.readConfigFileSnapshotForWrite();
if (!snapshot.valid) {
const issues =
@@ -398,7 +412,12 @@ export async function buildMigrationPlan(params: {
const stateDir = resolveStateDir(params.env, os.homedir);
const nextConfig = structuredClone(snapshot.config);
const fileSource = resolveFileSource(nextConfig, params.env);
const previousPayload = await decryptSopsJson(fileSource.path, fileSource.timeoutMs);
const sopsConfigPath = resolveExistingSopsConfigPath(params.env);
const previousPayload = await decryptSopsJson(
fileSource.path,
fileSource.timeoutMs,
sopsConfigPath,
);
const nextPayload = structuredClone(previousPayload);
const counters: MigrationCounters = {
@@ -518,6 +537,7 @@ export async function buildMigrationPlan(params: {
nextPayload,
secretsFilePath: fileSource.path,
secretsFileTimeoutMs: fileSource.timeoutMs,
sopsConfigPath,
envChange,
backupTargets: [...backupTargets],
};

View File

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

View File

@@ -2,7 +2,7 @@ import type { OpenClawConfig } from "../config/config.js";
import type { SecretRef } from "../config/types.secrets.js";
import { resolveUserPath } from "../utils.js";
import { readJsonPointer } from "./json-pointer.js";
import { isNonEmptyString, normalizePositiveInt } from "./shared.js";
import { isNonEmptyString, isRecord, normalizePositiveInt } from "./shared.js";
import { decryptSopsJsonFile, DEFAULT_SOPS_TIMEOUT_MS } from "./sops.js";
export type SecretRefResolveCache = {
@@ -39,6 +39,11 @@ async function resolveFileSecretPayload(options: ResolveSecretRefOptions): Promi
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");
}
return payload;
});
if (cache) {
cache.fileSecretsPromise = promise;

View File

@@ -133,6 +133,39 @@ describe("secrets runtime snapshot", () => {
);
});
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: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", id: "/providers/openai/apiKey" },
models: [],
},
},
},
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
}),
).rejects.toThrow("sops decrypt failed: decrypted payload is not a JSON object");
});
it("activates runtime snapshots for loadConfig and ensureAuthProfileStore", async () => {
const prepared = await prepareSecretsRuntimeSnapshot({
config: {

View File

@@ -34,10 +34,16 @@ export async function decryptSopsJsonFile(params: {
path: string;
timeoutMs?: number;
missingBinaryMessage: string;
configPath?: string;
}): Promise<unknown> {
const timeoutMs = normalizeTimeoutMs(params.timeoutMs);
try {
const { stdout } = await runExec("sops", ["--decrypt", "--output-type", "json", params.path], {
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,
});
@@ -61,6 +67,7 @@ export async function encryptSopsJsonFile(params: {
payload: Record<string, unknown>;
timeoutMs?: number;
missingBinaryMessage: string;
configPath?: string;
}): Promise<void> {
ensureDirForFile(params.path);
const timeoutMs = normalizeTimeoutMs(params.timeoutMs);
@@ -77,23 +84,24 @@ export async function encryptSopsJsonFile(params: {
fs.chmodSync(tmpPlain, 0o600);
try {
await runExec(
"sops",
[
"--encrypt",
"--input-type",
"json",
"--output-type",
"json",
"--output",
tmpEncrypted,
tmpPlain,
],
{
timeoutMs,
maxBuffer: MAX_SOPS_OUTPUT_BYTES,
},
const args: string[] = [];
if (typeof params.configPath === "string" && params.configPath.trim().length > 0) {
args.push("--config", params.configPath);
}
args.push(
"--encrypt",
"--input-type",
"json",
"--output-type",
"json",
"--output",
tmpEncrypted,
tmpPlain,
);
await runExec("sops", args, {
timeoutMs,
maxBuffer: MAX_SOPS_OUTPUT_BYTES,
});
fs.renameSync(tmpEncrypted, params.path);
fs.chmodSync(params.path, 0o600);
} catch (err) {