mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 15:14:31 +00:00
feat(secrets): finalize external secrets runtime and migration hardening
This commit is contained in:
committed by
Peter Steinberger
parent
c5b89fbaea
commit
0e69660c41
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
14
src/secrets/migrate/config-io.ts
Normal file
14
src/secrets/migrate/config-io.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ export type MigrationPlan = {
|
||||
nextPayload: Record<string, unknown>;
|
||||
secretsFilePath: string;
|
||||
secretsFileTimeoutMs: number;
|
||||
sopsConfigPath?: string;
|
||||
envChange: EnvChange | null;
|
||||
backupTargets: string[];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user